Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ffd92cff1 | ||
|
|
ab3dee2552 | ||
|
|
662bd6a743 | ||
|
|
0497bc2698 | ||
|
|
6befaadc21 | ||
|
|
7125681a98 | ||
|
|
e95ad6da4d | ||
|
|
1b7f50e885 | ||
|
|
8dd22f9347 | ||
|
|
89f3fa8aae |
+138
-4
@@ -437,10 +437,11 @@ the first nav link and heading button.
|
||||
Recorded shell deviations:
|
||||
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
|
||||
palette, then the OS.
|
||||
- **D5**: no Activity button or Activity row until an activity center exists.
|
||||
- **D5**: retired by Activity (#249 Activity-1). The concept's Activity button and
|
||||
Activity row are shown; their differences are D52 to D54.
|
||||
- **D28**: no COLLECTIONS, and no Design review. The bottom nav is
|
||||
System plus the production Overview hub (its own outline home glyph, not the concept's
|
||||
`activity` pulse, and no count).
|
||||
the concept's Activity and System, plus the production Overview hub (its own
|
||||
outline home glyph, not the concept's `activity` pulse, and no count).
|
||||
The connection reads "Your library, on your server" with a neutral muted dot.
|
||||
- **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED"
|
||||
badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are
|
||||
@@ -491,6 +492,138 @@ Recorded shell deviations:
|
||||
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
|
||||
route, star updates, Save view on Favorites, Edit, and the failure paths;
|
||||
`tests/test_image_favorites.py` covers `favorites=1`.
|
||||
- **D52** (Activity, #249 Activity-1): the top-bar `button#activity-open` and the
|
||||
sidebar's Activity row (the concept's `activity` pulse, first in `system-nav`) count
|
||||
this session's *running* jobs. The concept counts
|
||||
every sample job. `span.count#activity-count` is `hidden` at 0 (its text stays "0"),
|
||||
and the row has no `.nav-count` then. The Activity heading has no "Unified activity ·
|
||||
proposed" chip (prototype copy). At `max-width: 700px` the full-width `.top-actions`
|
||||
row may wrap (`flex-wrap: wrap`), so the button, Theme and ComfyUI stay inside a
|
||||
zoomed phone. The shell comparison hides the concept's count at runtime
|
||||
(`#activity-count { display: none }`), so the concept's own CSS lays out its button
|
||||
without it, and maps the row's count to none, the button's count to "0" and the
|
||||
phone row's `flexWrap` to `wrap`.
|
||||
- **D53**: while a job runs, the approved Activity mockup's progress pill sits before
|
||||
the Activity button on every page: `a#activity-pill.activity-pill`, "Scanning · 41% ·
|
||||
8,240 / 19,622" (the verb by job kind, the server's floored percentage, and the file
|
||||
counts once the total is known), a 70px mini bar, `href="#/activity/<id>"` and the same
|
||||
text as its `aria-label`. With no running job it is `hidden`, which the shell
|
||||
comparison maps as a hidden item before the button. At `max-width: 700px` it keeps the
|
||||
verb and percentage only; on coarse pointers it is 40px tall like the concept's
|
||||
buttons. Its rules are in `workspace.css` under D53.
|
||||
- **D54**: each `.activity-item` is a real job of this session (`GET
|
||||
/prompt_manager/jobs`), running first, then finished newest first. The concept's
|
||||
structure and CSS stay: `.panel-heading` > `h3` "✓ <title>" for a completed job and
|
||||
"○ <title>" otherwise, plus `span.chip`, then `p`, and for a running job `<progress
|
||||
max=100>` ("<title> progress") and `.row-actions`. The chip is green "Completed" or
|
||||
blue "Running", "Cancelled", "Failed" or "Ended". The line reads "Started HH:MM · K
|
||||
output folders · closing this view does not cancel it" while running, "Finished HH:MM
|
||||
· N added · M errors", "Cancelled HH:MM at a file boundary · N added · completed work
|
||||
is kept", "Stopped on an error at HH:MM · N added · M errors", or "This job ended when
|
||||
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>` 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, 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
|
||||
errors" when a job this tab saw running ends (plural helper for "error").
|
||||
`tests/js/workspace-activity-store.test.js` covers the store, its backoff and the
|
||||
copy; `tests/browser/workspace-activity.spec.mjs` covers the list against the
|
||||
concept's own items (`renderActivity` and `startJob`), the pill and counts, a phone,
|
||||
reload and a second tab, Cancel job, a restart and an unreachable server, and the
|
||||
toasts, with fake jobs from `tests/browser/workspace_activity_fixture.py`
|
||||
(`/__test__/jobs/*`, including a simulated restart that drops the job streams).
|
||||
- **D55** (Scan for metadata, Activity-1 Task 3): Images → Import keeps Rescan as the
|
||||
quick refresh (D15) and then shows the concept's own "Scan an output folder → Review
|
||||
scan" row as "Scan for metadata" ("Discover and enrich existing files using the
|
||||
configured sources.", button "Review scan"). It opens the concept's `scan-review`,
|
||||
"Review metadata scan": no sample-plan `.demo-note` (prototype copy); Sources and
|
||||
Scope in a `dl.meta-grid` (the concept's maintenance plan uses `dl`; `scan-review`
|
||||
uses a `div`) reading "N output folders" (the discovered sources) and "All files"
|
||||
(fixed in Activity-1; Activity-2 adds the scope option); the concept's sentence; then
|
||||
a backup `.notice` (the concept's System notice box): `h3` "Back up the database first
|
||||
(recommended)", "Downloads a copy of the prompts database before the scan changes any
|
||||
records." and "Back up now", which downloads the existing `/prompt_manager/backup`
|
||||
copy (as the legacy admin page does), reads "Backing up…" while aria-disabled, keeps
|
||||
the dialog open and toasts "Database backup downloaded." or "The database could not be
|
||||
backed up. Try again."; then Cancel / Start scan (primary; the concept's "Simulate
|
||||
scan"). Start scan closes the dialog, starts the session scan job (`POST
|
||||
/prompt_manager/jobs/scan`), toasts "Scan started." and opens `#/activity/<id>`; a
|
||||
failed start toasts "The scan could not start. Try again.". The scan's progress line
|
||||
now groups thousands ("Processing file 8,240 of 19,622"), as the mockup does.
|
||||
`tests/browser/activity-concept.spec.mjs` compares the dialog with the concept's
|
||||
`scan-review` and its box with the concept's System `.notice` (dark and light,
|
||||
Chromium and touch, with a CSS and a DOM mutation check); `images-concept.spec.mjs`
|
||||
maps the Import row; `workspace-images.spec.mjs` covers the dialog, the backup and
|
||||
Cancel.
|
||||
- **D56**: one job's view at `#/activity/<id>`, the approved mockup
|
||||
(`activity-scan.html`, "100% spot on"), which the concept does not have. The page
|
||||
heading names the job (h1, document title "<title> · Prompt Manager"; the breadcrumb
|
||||
stays "Activity"), its line is the D54 item line, and its actions are Download report
|
||||
(disabled until Activity-2) and, while the job runs, "Cancel scan" (`.danger`; "Cancel
|
||||
job" for another kind; a failed request toasts the D54 message). Then `section.surface`
|
||||
"Progress": `.panel-heading` (h2, `span.small` with the server's "Processing file N of
|
||||
M · about X min left", or after the end "N of M files processed", "Cancelled after N
|
||||
of M files", the error line, or the restart line), `<progress max=100>` ("Scan
|
||||
progress") and `dl.stat-row` tiles in order: Added to Library (`.added`, green),
|
||||
Already known, Needs review (`.warn`, warning), No usable metadata, Errors (`.bad`,
|
||||
red). Then `section.surface` "Results": `label.search-field` "Filter by file path…"
|
||||
(`aria-label` "Filter results", case-folded match on the root label and path), and
|
||||
`.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. 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 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
|
||||
colour. The rules are in `workspace.css` under D56. `activity-concept.spec.mjs`
|
||||
restates the mockup's structure and CSS values and checks them with mutations;
|
||||
`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, 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
|
||||
@@ -577,7 +710,8 @@ Recorded deviations on this page (spec §8):
|
||||
footer, masonry tops may differ by sub-pixel snapping (at most one layout unit per
|
||||
card above in the column), and a List row's info takes its natural height, centred
|
||||
beside the 160×95 art.
|
||||
- **D5**, **D29**: no Activity button and no prototype copy (shell).
|
||||
- **D29**: no prototype copy (shell). The Activity button and pill are the shell's
|
||||
(D52, D53).
|
||||
- **D7**: the star is a real persisted favorite with "Added to favorites." /
|
||||
"Removed from favorites." and a rollback toast on failure.
|
||||
- **D8**: every Images card has the concept's select checkbox before the star; the
|
||||
|
||||
+10
-2
@@ -6,6 +6,7 @@ Split into domain-specific mixins for maintainability:
|
||||
- AdminRoutesMixin: duplicates, stats, settings, diagnostics, maintenance, backup, scan
|
||||
- LoggingRoutesMixin: log management endpoints
|
||||
- AutotagRoutesMixin: auto-tagging model management and tagging
|
||||
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -29,6 +30,8 @@ from .saved_views import SavedViewRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
from .job_routes import JobRoutesMixin
|
||||
from .jobs import JobRegistry
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
@@ -254,6 +257,7 @@ class PromptManagerAPI(
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
AdminRoutesMixin,
|
||||
JobRoutesMixin,
|
||||
LoggingRoutesMixin,
|
||||
AutotagRoutesMixin,
|
||||
LoraIntegrationMixin,
|
||||
@@ -299,9 +303,12 @@ class PromptManagerAPI(
|
||||
# _acquire_long_job(). Created lazily so no lock binds to an event
|
||||
# loop before the server's loop exists.
|
||||
self._long_jobs: dict = {}
|
||||
# The current output scan (py/api/scan_job.ScanJob), if one was started.
|
||||
# This session's jobs (py/api/jobs.py): the metadata scan, and later
|
||||
# every other long operation the Activity page tracks.
|
||||
self._jobs = JobRegistry(self.logger)
|
||||
# The latest metadata scan job (also in self._jobs), if one was started.
|
||||
self._scan_job = None
|
||||
# The current duplicate scan (also a ScanJob), if one was started.
|
||||
# The current duplicate scan (a py/api/jobs.Job outside the registry).
|
||||
self._duplicate_job = None
|
||||
|
||||
# Run cleanup on initialization to remove any existing duplicates
|
||||
@@ -439,6 +446,7 @@ class PromptManagerAPI(
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
self._register_admin_routes(routes)
|
||||
self._register_job_routes(routes)
|
||||
self._register_logging_routes(routes)
|
||||
self._register_autotag_routes(routes)
|
||||
self._register_lora_routes(routes)
|
||||
|
||||
+230
-45
@@ -6,16 +6,18 @@ from contextlib import closing
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .prompts import thumbnail_url_for
|
||||
from .scan_job import ScanJob
|
||||
from .jobs import SCAN_GROUP_NAMES, SCAN_GROUPS, Job, JobBusy
|
||||
|
||||
try:
|
||||
from ...utils.parallel import map_parallel
|
||||
@@ -47,8 +49,10 @@ except ImportError:
|
||||
|
||||
|
||||
try:
|
||||
from ...database.library import display_title
|
||||
from ...utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from database.library import display_title
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff")
|
||||
@@ -138,6 +142,100 @@ def _sse(payload):
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
SCAN_JOB_KIND = "scan"
|
||||
SCAN_JOB_TITLE = "Scan for metadata"
|
||||
# The legacy final event type of a scan and the job state it ends in.
|
||||
SCAN_FINAL_STATES = {
|
||||
"complete": "finished",
|
||||
"cancelled": "cancelled",
|
||||
"error": "failed",
|
||||
}
|
||||
MIN_BATCHES_FOR_ESTIMATE = 2 # "about X min left" waits for this many batches
|
||||
# Log line prefix per outcome group (spec: + added, = known, ? review, ! error,
|
||||
# - no usable metadata).
|
||||
SCAN_LOG_PREFIXES = {
|
||||
"added": "+",
|
||||
"known": "=",
|
||||
"review": "?",
|
||||
"errors": "!",
|
||||
"no_metadata": "-",
|
||||
}
|
||||
# What an outcome row says about a file: where it went (with the prompt's
|
||||
# title) or why it was skipped. "error" is a file whose ingestion raised;
|
||||
# None is a file without prompt metadata.
|
||||
SCAN_TITLED_DETAILS = {
|
||||
"added": "Added to the Library as “{title}”",
|
||||
"linked": "Linked to “{title}”",
|
||||
"enriched": "Metadata added to “{title}”",
|
||||
"unchanged": "Already linked to “{title}”",
|
||||
}
|
||||
SCAN_DETAILS = {
|
||||
"added": "Added to the Library",
|
||||
"linked": "Linked to an existing prompt",
|
||||
"enriched": "Metadata added to its prompt",
|
||||
"unchanged": "Already linked to its prompt",
|
||||
"ambiguous": "Needs review: more than one prompt could match",
|
||||
"unresolved": "Needs review: the prompt could not be traced in the workflow",
|
||||
"conflict": "Needs review: already linked to a different prompt",
|
||||
"failed": "Error: the prompt could not be stored",
|
||||
"error": "Error: the file could not be processed (see the server log)",
|
||||
"found": "Metadata found, but no prompt text to store",
|
||||
None: "No prompt metadata in the file",
|
||||
}
|
||||
|
||||
|
||||
def _plural(count, word):
|
||||
return f"{count} {word}" if count == 1 else f"{count} {word}s"
|
||||
|
||||
|
||||
def _scan_status_line(done, total, batches, elapsed):
|
||||
""" "Processing file N of M", with an estimate once enough batches ran."""
|
||||
line = f"Processing file {done:,} of {total:,}"
|
||||
if batches < MIN_BATCHES_FOR_ESTIMATE or done <= 0 or done >= total:
|
||||
return line
|
||||
minutes = max(1, math.ceil(elapsed / done * (total - done) / 60))
|
||||
return f"{line} · about {minutes} min left"
|
||||
|
||||
|
||||
def _scan_roots(output_dirs):
|
||||
"""(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), 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, that root's label and index."""
|
||||
full = os.path.abspath(str(media_file))
|
||||
folded = os.path.normcase(full)
|
||||
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, index
|
||||
return Path(full).name, "", None
|
||||
|
||||
|
||||
def _scan_outcome(media_file, roots, status, title=None):
|
||||
"""The (group, row, log line) one scanned file records in its job."""
|
||||
if status in SCAN_GROUPS:
|
||||
group = SCAN_GROUPS[status]
|
||||
else:
|
||||
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, 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}"
|
||||
|
||||
|
||||
def _inspect_restore_upload(path):
|
||||
"""Checks on a restore upload beyond PromptModel.verify_database_file.
|
||||
|
||||
@@ -437,7 +535,7 @@ class AdminRoutesMixin:
|
||||
guard = self._acquire_long_job(self.LONG_JOB_DUPLICATES)
|
||||
if guard is None:
|
||||
return self._long_job_busy_response("Duplicate scan or delete")
|
||||
job = ScanJob()
|
||||
job = Job("duplicates", "Duplicate scan")
|
||||
self._duplicate_job = job
|
||||
job.task = asyncio.ensure_future(self._run_duplicate_job(job, guard))
|
||||
return await self._stream_scan_job(request, job)
|
||||
@@ -1319,14 +1417,18 @@ class AdminRoutesMixin:
|
||||
async def scan_images(self, request):
|
||||
"""Scan ComfyUI output images for prompt metadata and add them to the database.
|
||||
|
||||
The scan runs as a background task that outlives this request, so a
|
||||
closed tab never leaves a half-finished scan. Progress streams as
|
||||
server-sent events; while a scan is running, a new request attaches
|
||||
to it instead of starting another one.
|
||||
The scan runs as the registry's ``scan`` job, which outlives this
|
||||
request, so a closed tab never leaves a half-finished scan. Progress
|
||||
streams as the legacy server-sent events; while a scan is running
|
||||
(started here or from the workspace), a new request attaches to it
|
||||
instead of starting another one.
|
||||
"""
|
||||
job = self._scan_job
|
||||
if job is None or job.finished:
|
||||
job = self._start_scan_job()
|
||||
job = self._jobs.active(SCAN_JOB_KIND)
|
||||
if job is None:
|
||||
try:
|
||||
job = self._start_scan_job()
|
||||
except JobBusy as busy:
|
||||
return self._long_job_busy_response(busy.running.title)
|
||||
return await self._stream_scan_job(request, job)
|
||||
|
||||
async def cancel_scan(self, request):
|
||||
@@ -1349,18 +1451,33 @@ class AdminRoutesMixin:
|
||||
)
|
||||
|
||||
def _start_scan_job(self):
|
||||
"""Create the job record synchronously, then run the scan as a task."""
|
||||
job = ScanJob()
|
||||
"""Register the scan job synchronously; its runner starts on the loop.
|
||||
|
||||
Raises JobBusy while another record-changing job runs.
|
||||
"""
|
||||
job = self._jobs.start(
|
||||
SCAN_JOB_KIND,
|
||||
SCAN_JOB_TITLE,
|
||||
record_changing=True,
|
||||
scope={"mode": "all"},
|
||||
runner=self._run_scan_job,
|
||||
groups=SCAN_GROUP_NAMES,
|
||||
)
|
||||
self._scan_job = job
|
||||
job.task = asyncio.ensure_future(self._run_scan_job(job))
|
||||
return job
|
||||
|
||||
async def _run_scan_job(self, job):
|
||||
try:
|
||||
async for payload in self._scan_images_events():
|
||||
job.publish(payload)
|
||||
finally:
|
||||
job.finish()
|
||||
final = None
|
||||
async for payload in self._scan_images_events(job):
|
||||
job.publish(payload)
|
||||
final = payload.get("type")
|
||||
state = SCAN_FINAL_STATES.get(final, "finished")
|
||||
if state == "failed":
|
||||
job.log_line(f"Scan failed · {job.status_line}")
|
||||
else:
|
||||
verb = "cancelled" if state == "cancelled" else "finished"
|
||||
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
|
||||
job.finish(state)
|
||||
|
||||
async def _stream_scan_job(self, request, job):
|
||||
"""Stream ``job`` as SSE; stop quietly if the client goes away."""
|
||||
@@ -1386,21 +1503,30 @@ class AdminRoutesMixin:
|
||||
)
|
||||
return response
|
||||
|
||||
async def _scan_images_events(self):
|
||||
"""Yield progress payloads while scanning every configured output directory."""
|
||||
async def _scan_images_events(self, job):
|
||||
"""Yield progress payloads while scanning every configured output directory.
|
||||
|
||||
The payloads are the legacy SSE events, unchanged; ``job`` also gets
|
||||
its status line, totals and one outcome per processed file.
|
||||
"""
|
||||
try:
|
||||
self.logger.info("Starting image scan operation")
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
self.logger.error("No output directories found")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": "No output directories found. "
|
||||
"Configure scan directories in Settings.",
|
||||
}
|
||||
message = (
|
||||
"No output directories found. "
|
||||
"Configure scan directories in Settings."
|
||||
)
|
||||
job.update(status_line=message)
|
||||
yield {"type": "error", "message": message}
|
||||
return
|
||||
|
||||
dir_names = [self._public_path(d) for d in output_dirs]
|
||||
folders = _plural(len(output_dirs), "output folder")
|
||||
job.scope["output_folders"] = len(output_dirs)
|
||||
job.log_line(f"Scan started · {folders}")
|
||||
job.update(status_line=f"Looking for media files in {folders}")
|
||||
yield self._scan_progress(
|
||||
0, f"Scanning {len(output_dirs)} directory(ies) for media files..."
|
||||
)
|
||||
@@ -1409,11 +1535,16 @@ class AdminRoutesMixin:
|
||||
self._collect_output_media_sync, output_dirs
|
||||
)
|
||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||
job.update(
|
||||
total=len(media_files),
|
||||
status_line=_scan_status_line(0, len(media_files), 0, 0),
|
||||
)
|
||||
if media_files:
|
||||
yield self._scan_progress(
|
||||
5, f"Found {len(media_files)} media files to process...", counts
|
||||
)
|
||||
async for event in self._scan_batches(media_files, counts):
|
||||
roots = _scan_roots(output_dirs)
|
||||
async for event in self._scan_batches(media_files, counts, job, roots):
|
||||
yield event
|
||||
|
||||
self.logger.info(
|
||||
@@ -1421,37 +1552,55 @@ class AdminRoutesMixin:
|
||||
f"found={counts['found']}, new_prompts_added={counts['added']}, "
|
||||
f"images_linked_to_existing={counts['linked']}"
|
||||
)
|
||||
job = self._scan_job
|
||||
final_type = "cancelled" if job and job.cancelled else "complete"
|
||||
final_type = "cancelled" if job.cancelled else "complete"
|
||||
if final_type == "complete":
|
||||
job.update(progress=100)
|
||||
yield {"type": final_type, **counts, "directories": dir_names}
|
||||
|
||||
except Exception:
|
||||
self.logger.exception("Scan error")
|
||||
job.update(status_line="The scan stopped on an internal error.")
|
||||
yield {
|
||||
"type": "error",
|
||||
"message": "An internal error occurred. Check server logs for details.",
|
||||
}
|
||||
|
||||
async def _scan_batches(self, media_files, counts):
|
||||
async def _scan_batches(self, media_files, counts, job, roots=()):
|
||||
"""Process ``media_files`` in executor batches, updating ``counts`` in place.
|
||||
|
||||
Yields one progress event per batch.
|
||||
Records each file's outcome in ``job`` and yields one progress event
|
||||
per batch.
|
||||
"""
|
||||
total = len(media_files)
|
||||
started = time.monotonic()
|
||||
batches = 0
|
||||
for batch_start in range(0, total, SCAN_BATCH_SIZE):
|
||||
if self._scan_job and self._scan_job.cancelled:
|
||||
if job.cancelled:
|
||||
return
|
||||
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
|
||||
batch_results = await self._run_in_executor(
|
||||
self._extract_batch_metadata_sync, batch
|
||||
)
|
||||
outcomes = []
|
||||
batch_counts = await self._run_in_executor(
|
||||
self._ingest_scan_batch_sync, batch_results
|
||||
self._ingest_scan_batch_sync,
|
||||
batch_results,
|
||||
roots=roots,
|
||||
outcomes=outcomes,
|
||||
)
|
||||
for key in counts:
|
||||
counts[key] += batch_counts[key]
|
||||
for group, row, log_line in outcomes:
|
||||
job.record(group, row, log_line)
|
||||
|
||||
batches += 1
|
||||
done = min(batch_start + len(batch), total)
|
||||
job.update(
|
||||
processed=counts["processed"],
|
||||
status_line=_scan_status_line(
|
||||
done, total, batches, time.monotonic() - started
|
||||
),
|
||||
)
|
||||
yield self._scan_progress(
|
||||
int(done / total * 100), f"Processing file {done}/{total}...", counts
|
||||
)
|
||||
@@ -1490,9 +1639,19 @@ class AdminRoutesMixin:
|
||||
|
||||
return map_parallel(extract, file_batch, PromptManagerConfig.WORKER_THREADS)
|
||||
|
||||
def _ingest_scan_batch_sync(self, batch_results):
|
||||
"""Store the prompts found in a metadata batch (blocking DB work)."""
|
||||
def _ingest_scan_batch_sync(self, batch_results, roots=(), outcomes=None):
|
||||
"""Store the prompts found in a metadata batch (blocking DB work).
|
||||
|
||||
Returns the legacy counts. When ``outcomes`` is a list, every processed
|
||||
file appends its ``(group, row, log_line)`` for the scan job, with the
|
||||
path relative to its root in ``roots`` (see ``_scan_roots``).
|
||||
"""
|
||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||
|
||||
def note(media_file, status, title=None):
|
||||
if outcomes is not None:
|
||||
outcomes.append(_scan_outcome(media_file, roots, status, title))
|
||||
|
||||
for media_file, metadata in batch_results:
|
||||
if self._scan_job and self._scan_job.cancelled:
|
||||
break
|
||||
@@ -1502,25 +1661,21 @@ class AdminRoutesMixin:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing {media_file.name}: {e}")
|
||||
counts["failed"] += 1
|
||||
note(media_file, "error")
|
||||
continue
|
||||
if outcome is None:
|
||||
note(media_file, None)
|
||||
continue
|
||||
counts["found"] += 1
|
||||
status = outcome["status"] if isinstance(outcome, dict) else outcome
|
||||
if status in (
|
||||
"added",
|
||||
"linked",
|
||||
"enriched",
|
||||
"unchanged",
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
"conflict",
|
||||
"failed",
|
||||
):
|
||||
if status in SCAN_GROUPS:
|
||||
counts[status] += 1
|
||||
if isinstance(outcome, dict):
|
||||
counts["pairs_added"] += outcome.get("pairs_added", 0)
|
||||
counts["pair_links_added"] += outcome.get("links_added", 0)
|
||||
note(media_file, status, outcome.get("title"))
|
||||
else:
|
||||
note(media_file, status)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
@@ -1539,7 +1694,13 @@ class AdminRoutesMixin:
|
||||
return positive.strip() or None
|
||||
|
||||
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
|
||||
|
||||
Returns the file's scan status, or None without prompt metadata. With
|
||||
``detailed``, a stored or linked file returns a dict instead: its
|
||||
``status``, the prompt's display ``title`` and, for a resolved pair,
|
||||
the pair counts.
|
||||
"""
|
||||
if not metadata:
|
||||
return None
|
||||
canonical = dict(metadata)
|
||||
@@ -1557,7 +1718,12 @@ class AdminRoutesMixin:
|
||||
result = ingest_resolved_pair(
|
||||
self.db, str(media_file), canonical, resolution
|
||||
)
|
||||
return result if detailed else result["status"]
|
||||
if not detailed:
|
||||
return result["status"]
|
||||
return {
|
||||
**result,
|
||||
"title": self._scan_title(media_file, resolution["positive"]["text"]),
|
||||
}
|
||||
if canonical.get("prompt") and resolution["state"] in (
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
@@ -1585,6 +1751,25 @@ class AdminRoutesMixin:
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
|
||||
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": 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."""
|
||||
prompt_hash = generate_prompt_hash(prompt_text)
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Session job routes (Activity): list, inspect, follow and cancel jobs.
|
||||
|
||||
The registry itself is ``py/api/jobs.py``; the scan job's runner is in
|
||||
``py/api/admin.py``. Every response leaves out absolute server paths: job
|
||||
rows already carry root-relative paths and public root labels.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .jobs import JobBusy
|
||||
|
||||
|
||||
def _not_found():
|
||||
return web.json_response({"success": False, "error": "Job not found"}, status=404)
|
||||
|
||||
|
||||
def _summary_event(job):
|
||||
return f"data: {json.dumps(job.summary())}\n\n".encode("utf-8")
|
||||
|
||||
|
||||
class JobRoutesMixin:
|
||||
"""Mixin providing the /prompt_manager/jobs endpoints."""
|
||||
|
||||
def _register_job_routes(self, routes):
|
||||
@routes.get("/prompt_manager/jobs")
|
||||
async def list_jobs_route(request):
|
||||
return await self.list_jobs(request)
|
||||
|
||||
@routes.post("/prompt_manager/jobs/scan")
|
||||
async def start_scan_job_route(request):
|
||||
return await self.start_scan_job(request)
|
||||
|
||||
@routes.get("/prompt_manager/jobs/{job_id}")
|
||||
async def job_detail_route(request):
|
||||
return await self.job_detail(request)
|
||||
|
||||
@routes.get("/prompt_manager/jobs/{job_id}/events")
|
||||
async def job_events_route(request):
|
||||
return await self.job_events(request)
|
||||
|
||||
@routes.post("/prompt_manager/jobs/{job_id}/cancel")
|
||||
async def cancel_job_route(request):
|
||||
return await self.cancel_job(request)
|
||||
|
||||
async def list_jobs(self, request):
|
||||
"""This session's jobs: running first, then finished, newest first."""
|
||||
jobs = [job.summary() for job in self._jobs.list()]
|
||||
return web.json_response({"success": True, "jobs": jobs})
|
||||
|
||||
async def job_detail(self, request):
|
||||
job = self._jobs.get(request.match_info["job_id"])
|
||||
if job is None:
|
||||
return _not_found()
|
||||
return web.json_response({"success": True, "job": job.detail()})
|
||||
|
||||
async def cancel_job(self, request):
|
||||
"""Stop a job at its next file boundary; a finished job is a no-op."""
|
||||
job_id = request.match_info["job_id"]
|
||||
if self._jobs.get(job_id) is None:
|
||||
return _not_found()
|
||||
requested = self._jobs.cancel(job_id)
|
||||
return web.json_response({"success": True, "cancellation_requested": requested})
|
||||
|
||||
async def start_scan_job(self, request):
|
||||
"""Start the metadata scan as a job (202), or 409 naming the busy job."""
|
||||
try:
|
||||
job = self._start_scan_job()
|
||||
except JobBusy as busy:
|
||||
running = busy.running
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": str(busy),
|
||||
"running": {"id": running.id, "title": running.title},
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
return web.json_response({"success": True, "job": job.summary()}, status=202)
|
||||
|
||||
async def job_events(self, request):
|
||||
"""Stream the job's summary as SSE: now, after each event, and at the end."""
|
||||
job = self._jobs.get(request.match_info["job_id"])
|
||||
if job is None:
|
||||
return _not_found()
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
try:
|
||||
await response.write(_summary_event(job))
|
||||
async with contextlib.aclosing(job.events()) as events:
|
||||
async for _payload in events:
|
||||
await response.write(_summary_event(job))
|
||||
# The last event is published before the job takes its final state.
|
||||
await response.write(_summary_event(job))
|
||||
await response.write_eof()
|
||||
except Exception as e: # the socket closed under us; the job goes on
|
||||
self.logger.info(f"Job progress client disconnected ({e})")
|
||||
return response
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
"""The session job registry: every long operation as one tracked job.
|
||||
|
||||
A job runs as an asyncio task that outlives the HTTP request that started it,
|
||||
so an accidental tab close never abandons half-finished work. Each job keeps
|
||||
its counters, a capped list of file outcomes per outcome group and a capped
|
||||
log, and fans its progress out to SSE subscribers: the request that started
|
||||
it, a reconnecting page, a second tab. A subscriber receives the latest event
|
||||
as a snapshot and then every event published after it, until the job ends.
|
||||
|
||||
Jobs live only in server memory and are gone after a restart. The registry
|
||||
keeps the running jobs and this session's last ``MAX_FINISHED_JOBS`` finished
|
||||
ones. Record-changing jobs (the metadata scan, and later maintenance, cleanup
|
||||
and restore) never run together, and one job of a kind runs at a time.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Start and finish order: wall-clock times can tie (coarse Windows clocks).
|
||||
_sequence = itertools.count()
|
||||
|
||||
JOB_STATES = ("running", "finished", "cancelled", "failed")
|
||||
FINAL_STATES = JOB_STATES[1:]
|
||||
MAX_OUTCOME_ROWS = 200 # rows listed per outcome group; counters stay exact
|
||||
MAX_LOG_LINES = 2000
|
||||
LOG_CAP_LINE = "[Log capped at 2,000 lines]"
|
||||
MAX_FINISHED_JOBS = 20 # finished jobs this session keeps
|
||||
|
||||
# The metadata scan's outcome groups, in counter-tile order of the spec's
|
||||
# mapping, and the per-file scan status each group collects. Files without
|
||||
# usable metadata have no status and go to "no_metadata".
|
||||
SCAN_GROUP_NAMES = ("added", "known", "review", "no_metadata", "errors")
|
||||
SCAN_GROUPS = {
|
||||
"added": "added",
|
||||
"linked": "added",
|
||||
"enriched": "added",
|
||||
"unchanged": "known",
|
||||
"ambiguous": "review",
|
||||
"conflict": "review",
|
||||
"unresolved": "review",
|
||||
"failed": "errors",
|
||||
}
|
||||
|
||||
|
||||
def _relative_path(path) -> str:
|
||||
"""``path`` as a root-relative POSIX path; an absolute one keeps its name."""
|
||||
text = str(path or "")
|
||||
if (
|
||||
PurePosixPath(text).is_absolute()
|
||||
or PureWindowsPath(text).is_absolute()
|
||||
or PureWindowsPath(text).drive
|
||||
):
|
||||
return re.split(r"[\\/]", text)[-1]
|
||||
return text.replace("\\", "/")
|
||||
|
||||
|
||||
class JobBusy(Exception):
|
||||
"""A job could not start because ``running`` holds its slot."""
|
||||
|
||||
def __init__(self, running):
|
||||
super().__init__(f"{running.title} is already running")
|
||||
self.running = running
|
||||
|
||||
|
||||
class Job:
|
||||
"""One tracked operation: state, counters, outcomes, log and subscribers."""
|
||||
|
||||
def __init__(
|
||||
self, kind, title, *, record_changing=False, scope=None, groups=(), clock=None
|
||||
):
|
||||
self._clock = clock or time.time
|
||||
self.id = uuid.uuid4().hex
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.record_changing = record_changing
|
||||
self.scope = dict(scope or {})
|
||||
self.state = "running"
|
||||
self.started_at = self._clock()
|
||||
self._started_seq = next(_sequence)
|
||||
self._finished_seq = None
|
||||
self.finished_at = None
|
||||
self.progress = 0
|
||||
self.status_line = ""
|
||||
self.processed = 0
|
||||
self.total = 0
|
||||
self.counters = dict.fromkeys(groups, 0)
|
||||
self.outcomes = {group: [] for group in groups}
|
||||
self.log = []
|
||||
self.cancelled = False
|
||||
self.last_event = None
|
||||
self.task = None
|
||||
self._subscribers = set()
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self.state == "running"
|
||||
|
||||
@property
|
||||
def finished(self):
|
||||
return not self.running
|
||||
|
||||
@property
|
||||
def subscriber_count(self):
|
||||
return len(self._subscribers)
|
||||
|
||||
# ── progress ──────────────────────────────────────────────────────
|
||||
|
||||
def update(self, *, progress=None, status_line=None, processed=None, total=None):
|
||||
"""Set the summary's progress fields; ``None`` leaves one unchanged."""
|
||||
if progress is not None:
|
||||
self.progress = max(0, min(100, int(progress)))
|
||||
if status_line is not None:
|
||||
self.status_line = status_line
|
||||
if processed is not None:
|
||||
self.processed = processed
|
||||
if total is not None:
|
||||
self.total = total
|
||||
|
||||
def publish(self, payload):
|
||||
"""Record ``payload`` as the latest event and hand it to every subscriber."""
|
||||
if isinstance(payload.get("progress"), (int, float)):
|
||||
self.update(progress=payload["progress"])
|
||||
self.last_event = payload
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(payload)
|
||||
|
||||
def log_line(self, text):
|
||||
"""Append a timestamped log line, up to MAX_LOG_LINES and one cap line."""
|
||||
if len(self.log) > MAX_LOG_LINES:
|
||||
return
|
||||
if len(self.log) == MAX_LOG_LINES:
|
||||
self.log.append(LOG_CAP_LINE)
|
||||
return
|
||||
stamp = time.strftime("%H:%M:%S", time.localtime(self._clock()))
|
||||
self.log.append(f"{stamp} {text}")
|
||||
|
||||
def record(self, group, row, log_line):
|
||||
"""Count one file outcome in ``group``, list its row and log it.
|
||||
|
||||
``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}")
|
||||
self.counters[group] += 1
|
||||
raw = str(row.get("path") or "")
|
||||
path = _relative_path(raw)
|
||||
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 ""),
|
||||
}
|
||||
)
|
||||
self.log_line(log_line)
|
||||
|
||||
def finish(self, state="finished"):
|
||||
"""End the job in ``state`` and release every subscriber.
|
||||
|
||||
Each subscriber still gets the events queued before the release. A
|
||||
second finish is a no-op, so the first final state stands.
|
||||
"""
|
||||
if state not in FINAL_STATES:
|
||||
raise ValueError(f"{state!r} is not a final job state")
|
||||
if self.finished:
|
||||
return
|
||||
self.state = state
|
||||
self.finished_at = self._clock()
|
||||
self._finished_seq = next(_sequence)
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(None)
|
||||
|
||||
async def events(self):
|
||||
"""Yield the latest event, then each later one, until the job finishes.
|
||||
|
||||
Consume it under ``contextlib.aclosing`` so a consumer that stops early
|
||||
(a closed socket) drops its queue at once instead of at garbage collection.
|
||||
"""
|
||||
queue = asyncio.Queue()
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
if self.last_event is not None:
|
||||
yield self.last_event
|
||||
if self.finished:
|
||||
return
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
if payload is None:
|
||||
return
|
||||
yield payload
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
|
||||
# ── views ─────────────────────────────────────────────────────────
|
||||
|
||||
def summary(self):
|
||||
"""The job as the Activity list and pill show it."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"kind": self.kind,
|
||||
"title": self.title,
|
||||
"state": self.state,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"progress": self.progress,
|
||||
"status_line": self.status_line,
|
||||
"processed": self.processed,
|
||||
"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):
|
||||
"""The summary plus the listed outcome rows and the log."""
|
||||
return {
|
||||
**self.summary(),
|
||||
"outcomes": {
|
||||
group: [dict(row) for row in rows]
|
||||
for group, rows in self.outcomes.items()
|
||||
},
|
||||
"log": list(self.log),
|
||||
}
|
||||
|
||||
|
||||
class JobRegistry:
|
||||
"""Every job of this server session, running and recently finished."""
|
||||
|
||||
def __init__(self, logger_=None):
|
||||
self._logger = logger_ or logger
|
||||
self._jobs = {}
|
||||
|
||||
def start(self, kind, title, *, record_changing, scope, runner, groups=()) -> Job:
|
||||
"""Create a job and run ``runner(job)`` as a task.
|
||||
|
||||
Raises JobBusy, naming the job in the way, when a job of ``kind`` is
|
||||
running or when ``record_changing`` and another record-changing job
|
||||
runs. Must be called from the event loop.
|
||||
"""
|
||||
same_kind = self.active(kind)
|
||||
if same_kind is not None:
|
||||
raise JobBusy(same_kind)
|
||||
if record_changing:
|
||||
for job in self._jobs.values():
|
||||
if job.running and job.record_changing:
|
||||
raise JobBusy(job)
|
||||
self._prune()
|
||||
job = Job(
|
||||
kind, title, record_changing=record_changing, scope=scope, groups=groups
|
||||
)
|
||||
self._jobs[job.id] = job
|
||||
job.task = asyncio.ensure_future(self._run(job, runner))
|
||||
return job
|
||||
|
||||
async def _run(self, job, runner):
|
||||
try:
|
||||
await runner(job)
|
||||
except asyncio.CancelledError:
|
||||
job.finish("cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
self._logger.exception(f"Job {job.kind} failed")
|
||||
job.finish("failed")
|
||||
finally:
|
||||
if job.running:
|
||||
job.finish("cancelled" if job.cancelled else "finished")
|
||||
|
||||
def _prune(self):
|
||||
"""Drop the oldest finished jobs so a new one keeps the cap."""
|
||||
finished = [job for job in self._jobs.values() if job.finished]
|
||||
finished.sort(key=lambda job: job._finished_seq)
|
||||
for job in finished[: max(0, len(finished) - MAX_FINISHED_JOBS + 1)]:
|
||||
del self._jobs[job.id]
|
||||
|
||||
def get(self, job_id):
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def active(self, kind):
|
||||
"""The running job of ``kind``, or None."""
|
||||
for job in self._jobs.values():
|
||||
if job.kind == kind and job.running:
|
||||
return job
|
||||
return None
|
||||
|
||||
def list(self):
|
||||
"""Running jobs first, then the last finished ones, newest first."""
|
||||
jobs = list(self._jobs.values())
|
||||
running = [job for job in jobs if job.running]
|
||||
finished = [job for job in jobs if job.finished]
|
||||
running.sort(key=lambda job: job._started_seq, reverse=True)
|
||||
finished.sort(key=lambda job: job._finished_seq, reverse=True)
|
||||
return running + finished[:MAX_FINISHED_JOBS]
|
||||
|
||||
def cancel(self, job_id) -> bool:
|
||||
"""Ask a running job to stop at its next safe boundary.
|
||||
|
||||
Returns whether a cancellation was requested: False for a finished or
|
||||
unknown job, so a cancel after the end is a no-op.
|
||||
"""
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None or job.finished:
|
||||
return False
|
||||
job.cancelled = True
|
||||
return True
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Progress fan-out for the background output scan.
|
||||
|
||||
The scan runs as an asyncio task that outlives the HTTP request that started
|
||||
it, so an accidental tab close does not abandon a half-finished scan. Every
|
||||
SSE subscriber (the request that started the scan, a reconnecting page, a
|
||||
second tab) receives the latest event as a snapshot and then every event
|
||||
published after it, until the job finishes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class ScanJob:
|
||||
"""One scan run: its latest event, completion flag and live subscribers."""
|
||||
|
||||
def __init__(self):
|
||||
self.last_event = None
|
||||
self.finished = False
|
||||
self.cancelled = False
|
||||
self.task = None
|
||||
self._subscribers = set()
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return not self.finished
|
||||
|
||||
@property
|
||||
def subscriber_count(self):
|
||||
return len(self._subscribers)
|
||||
|
||||
def publish(self, payload):
|
||||
"""Record ``payload`` as the latest event and hand it to every subscriber."""
|
||||
self.last_event = payload
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(payload)
|
||||
|
||||
def finish(self):
|
||||
"""Mark the job done and release every subscriber after its queued events."""
|
||||
self.finished = True
|
||||
for queue in list(self._subscribers):
|
||||
queue.put_nowait(None)
|
||||
|
||||
async def events(self):
|
||||
"""Yield the latest event, then each later one, until the job finishes.
|
||||
|
||||
Consume it under ``contextlib.aclosing`` so a consumer that stops early
|
||||
(a closed socket) drops its queue at once instead of at garbage collection.
|
||||
"""
|
||||
queue = asyncio.Queue()
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
if self.last_event is not None:
|
||||
yield self.last_event
|
||||
if self.finished:
|
||||
return
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
if payload is None:
|
||||
return
|
||||
yield payload
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
@@ -0,0 +1,257 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
|
||||
// Activity-1 Task 3. The review dialog is compared with the concept's own `scan-review` ("Review metadata scan",
|
||||
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
|
||||
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
|
||||
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
});
|
||||
|
||||
/** The open review dialog: its body's element sequence, the meta grid, the sentence, the notice and the actions. */
|
||||
function measureReview() {
|
||||
const dialog = document.querySelector('dialog#modal[open]');
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const grid = dialog.querySelector('#modal-body > .meta-grid');
|
||||
const sentence = dialog.querySelector('#modal-body > p.small');
|
||||
const actions = dialog.querySelector('#modal-body > .dialog-actions');
|
||||
return {
|
||||
title: dialog.querySelector('#modal-title').textContent,
|
||||
body: [...dialog.querySelector('#modal-body').children].map(name),
|
||||
grid: {
|
||||
tag: grid.localName,
|
||||
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap', 'marginTop', 'marginBottom', 'fontSize'),
|
||||
entries: [...grid.children].map(entry => ({
|
||||
tag: entry.localName, dt: entry.querySelector('dt').textContent, dd: entry.querySelector('dd').textContent,
|
||||
dtStyle: css(entry.querySelector('dt'), 'color', 'fontSize'),
|
||||
ddStyle: css(entry.querySelector('dd'), 'marginTop', 'marginLeft', 'fontSize', 'color'),
|
||||
})),
|
||||
},
|
||||
sentence: { text: sentence.textContent, ...css(sentence, 'fontSize', 'color', 'lineHeight', 'marginTop') },
|
||||
actions: {
|
||||
...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||||
buttons: [...actions.children].map(button => ({ text: button.textContent, className: button.className,
|
||||
...css(button, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius') })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A `.notice` box: its frame and its text and actions columns, without the copy. */
|
||||
function measureNotice(selector) {
|
||||
const notice = document.querySelector(selector);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
return {
|
||||
frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopStyle',
|
||||
'borderTopColor', 'backgroundColor', 'borderTopLeftRadius', 'paddingTop', 'paddingLeft'),
|
||||
columns: [...notice.children].map(node => node.localName + (node.className ? `.${node.className}` : '')),
|
||||
text: css(notice.querySelector(':scope > div > p'), 'fontSize', 'color', 'marginTop'),
|
||||
actions: css(notice.querySelector(':scope > .row-actions'), 'display', 'flexShrink'),
|
||||
};
|
||||
}
|
||||
|
||||
/** D55: the concept's dialog with the production differences applied. */
|
||||
function mapReviewConcept(reference) {
|
||||
return {
|
||||
...reference,
|
||||
// No sample-plan note (prototype copy); the backup box sits before the actions.
|
||||
body: [...reference.body.filter(item => item !== 'p.demo-note').slice(0, -1), 'div.notice', reference.body.at(-1)]
|
||||
.map(item => item === 'div.meta-grid' ? 'dl.meta-grid' : item),
|
||||
// A `dl`, as in the concept's maintenance plan; real sources and the fixed Activity-1 scope.
|
||||
grid: { ...reference.grid, tag: 'dl', entries: reference.grid.entries.map(entry => ({ ...entry,
|
||||
dd: { Sources: '2 output folders', Scope: 'All files' }[entry.dt] })) },
|
||||
actions: { ...reference.actions, buttons: reference.actions.buttons.map(button => button.text === 'Simulate scan'
|
||||
? { ...button, text: 'Start scan' } : button) },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptReview(page, theme) {
|
||||
await openConcept(page, 'images', theme);
|
||||
await page.locator('#main .heading-actions').getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await page.locator('dialog#modal[open] .operation').filter({ hasText: 'Scan an output folder' })
|
||||
.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review metadata scan');
|
||||
await page.mouse.move(0, 0);
|
||||
const review = await page.evaluate(measureReview);
|
||||
await page.keyboard.press('Escape');
|
||||
// The concept's System → Sources notice ("Bring older images into your prompt history").
|
||||
await page.goto(`/__test__/canonical/workspace#system`);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await page.locator('#main').getByRole('button', { name: 'Manage sources', exact: true }).click();
|
||||
await expect(page.locator('#main .notice')).toBeVisible();
|
||||
return { review, notice: await page.evaluate(measureNotice, '#main .notice') };
|
||||
}
|
||||
|
||||
async function productionReview(page, theme) {
|
||||
await openProduction(page, 'images', theme);
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await page.locator('dialog#modal[open] .operation').filter({ hasText: 'Scan for metadata' })
|
||||
.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review metadata scan');
|
||||
// The swapped dialog can open under the pointer that pressed Review scan; measure it at rest.
|
||||
await page.mouse.move(0, 0);
|
||||
return { review: await page.evaluate(measureReview), notice: await page.evaluate(measureNotice, 'dialog#modal[open] .notice') };
|
||||
}
|
||||
|
||||
for (const theme of THEMES) test(`review dialog matches the concept scan-review ${theme}`, async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await seedConcept(request);
|
||||
const concept = await conceptReview(page, theme);
|
||||
expect(concept.review.body).toEqual(['p.demo-note', 'div.meta-grid', 'p.small', 'div.dialog-actions']);
|
||||
const production = await productionReview(page, theme);
|
||||
expect(diffInventory(mapReviewConcept(concept.review), production.review)).toEqual([]);
|
||||
expect(diffInventory(concept.notice, production.notice)).toEqual([]);
|
||||
await expect(page.locator('dialog#modal[open] .notice h3')).toHaveText('Back up the database first (recommended)');
|
||||
await expect(page.locator('dialog#modal[open] .notice .row-actions button')).toHaveText(['Back up now']);
|
||||
|
||||
// Mutation check: one CSS change and one removed element are both noticed, then undone to an exact match again.
|
||||
const undoCss = await mutate(page, { css: '#modal .meta-grid { gap: 3px; } #modal .notice { padding: 2px; }' });
|
||||
const cssMutated = await page.evaluate(measureReview);
|
||||
const noticeMutated = await page.evaluate(measureNotice, 'dialog#modal[open] .notice');
|
||||
expect(diffInventory(mapReviewConcept(concept.review), cssMutated).map(item => item.path)).toEqual(expect.arrayContaining(['grid.columnGap', 'grid.rowGap']));
|
||||
expect(diffInventory(concept.notice, noticeMutated).map(item => item.path)).toEqual(['frame.paddingTop', 'frame.paddingLeft']);
|
||||
await undoCss();
|
||||
const undoDom = await mutate(page, { remove: ['#modal-body > p.small'] });
|
||||
const domMutated = await page.evaluate(() => [...document.querySelectorAll('#modal-body > *')].map(node => node.localName));
|
||||
expect(domMutated).toEqual(['dl', 'div', 'div']);
|
||||
await undoDom();
|
||||
expect(diffInventory(mapReviewConcept(concept.review), await page.evaluate(measureReview))).toEqual([]);
|
||||
});
|
||||
|
||||
// The approved mockup's job view, restated: structure first, then the values of its CSS rules.
|
||||
const MOCKUP_TREE = ['div.heading', 'section.surface', 'section.surface', 'details.term'];
|
||||
const MOCKUP_PROGRESS = ['div.panel-heading(h2,span.small)', 'progress', 'dl.stat-row(div.added.stat,div.stat,div.stat.warn,div.stat,div.bad.stat)'];
|
||||
const MOCKUP_RESULTS = ['div.panel-heading(h2,label.search-field)', 'div.outcomes(div.outcome-list,div.outcome-files)'];
|
||||
const MOCKUP_STYLES = {
|
||||
statRow: { display: 'grid', columnGap: '10px', columns: 5, marginTop: '16px', marginBottom: '16px' },
|
||||
stat: { borderTopWidth: '1px', borderTopLeftRadius: '7px', paddingTop: '12px', paddingLeft: '14px' },
|
||||
dt: { fontSize: '11px' },
|
||||
dd: { fontSize: '20px', fontWeight: '600', marginTop: '6px', marginLeft: '0px' },
|
||||
outcomes: { display: 'grid', columnGap: '16px', first: '220px', marginTop: '6px' },
|
||||
outcomeButton: { display: 'flex', justifyContent: 'space-between', marginBottom: '6px', textAlign: 'left' },
|
||||
fileRow: { display: 'grid', columnGap: '12px', paddingTop: '9px', paddingLeft: '4px', borderBottomWidth: '1px', fontSize: '12px' },
|
||||
path: { monospace: true, overflowWrap: 'anywhere' },
|
||||
search: { maxWidth: '320px' },
|
||||
term: { marginTop: '16px', borderTopWidth: '1px', borderTopLeftRadius: '7px', overflow: 'hidden' },
|
||||
summary: { paddingTop: '11px', paddingLeft: '14px', fontSize: '12px', cursor: 'pointer' },
|
||||
pre: { marginTop: '0px', paddingTop: '12px', paddingLeft: '14px', maxHeight: '180px', fontSize: '11px', lineHeight: '18.7px', overflow: 'auto' },
|
||||
};
|
||||
|
||||
function measureJobView() {
|
||||
const main = document.querySelector('#workspace-main');
|
||||
const css = (node, ...keys) => Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
// The rows and sections the mockup nests are listed with their children.
|
||||
const shape = node => node.matches('.panel-heading, .outcomes, dl.stat-row')
|
||||
? `${name(node)}(${[...node.children].map(name).join(',')})` : name(node);
|
||||
const [progress, results] = main.querySelectorAll(':scope > section.surface');
|
||||
const statRow = progress.querySelector('dl.stat-row');
|
||||
const stat = statRow.querySelector('.stat');
|
||||
const outcomes = results.querySelector('.outcomes');
|
||||
const button = outcomes.querySelector('.outcome-list > button');
|
||||
const row = outcomes.querySelector('.file-row');
|
||||
const term = main.querySelector(':scope > details.term');
|
||||
const pre = term.querySelector('pre');
|
||||
const columns = getComputedStyle(statRow).gridTemplateColumns.split(' ');
|
||||
return {
|
||||
tree: [...main.children].map(name),
|
||||
progress: [...progress.children].map(shape),
|
||||
results: [...results.children].map(shape),
|
||||
h2: [...main.querySelectorAll(':scope > section.surface > .panel-heading > h2')].map(node => node.textContent),
|
||||
summary: term.querySelector('summary').textContent,
|
||||
styles: {
|
||||
statRow: { ...css(statRow, 'display', 'columnGap', 'marginTop', 'marginBottom'), columns: columns.length },
|
||||
stat: css(stat, 'borderTopWidth', 'borderTopLeftRadius', 'paddingTop', 'paddingLeft'),
|
||||
dt: css(stat.querySelector('dt'), 'fontSize'),
|
||||
dd: css(stat.querySelector('dd'), 'fontSize', 'fontWeight', 'marginTop', 'marginLeft'),
|
||||
outcomes: { ...css(outcomes, 'display', 'columnGap', 'marginTop'), first: getComputedStyle(outcomes).gridTemplateColumns.split(' ')[0] },
|
||||
outcomeButton: css(button, 'display', 'justifyContent', 'marginBottom', 'textAlign'),
|
||||
fileRow: css(row, 'display', 'columnGap', 'paddingTop', 'paddingLeft', 'borderBottomWidth', 'fontSize'),
|
||||
path: { monospace: /monospace/.test(getComputedStyle(row.querySelector('.path')).fontFamily),
|
||||
overflowWrap: getComputedStyle(row.querySelector('.path')).overflowWrap },
|
||||
search: css(results.querySelector('.search-field'), 'maxWidth'),
|
||||
term: css(term, 'marginTop', 'borderTopWidth', 'borderTopLeftRadius', 'overflow'),
|
||||
summary: css(term.querySelector('summary'), 'paddingTop', 'paddingLeft', 'fontSize', 'cursor'),
|
||||
// WebKit reports the 1.7 line height of 11px as 18.700001px; compare it to the hundredth of a pixel.
|
||||
pre: { ...css(pre, 'marginTop', 'paddingTop', 'paddingLeft', 'maxHeight', 'fontSize', 'overflow'),
|
||||
lineHeight: `${Math.round(parseFloat(getComputedStyle(pre).lineHeight) * 100) / 100}px` },
|
||||
},
|
||||
// Mockup colours: the pressed outcome takes the accent border on the soft blue, the tiles their tone.
|
||||
pressed: (() => {
|
||||
const probe = document.createElement('span');
|
||||
main.append(probe);
|
||||
const tone = name => { probe.style.color = `var(${name})`; return getComputedStyle(probe).color; };
|
||||
const values = { accent: tone('--ca-accent'), soft: tone('--soft-blue'), green: tone('--green'), red: tone('--red'), warning: tone('--warning') };
|
||||
probe.remove();
|
||||
const on = outcomes.querySelector('.outcome-list > button[aria-pressed="true"]');
|
||||
const dd = selector => getComputedStyle(statRow.querySelector(`${selector} dd`)).color;
|
||||
return { border: getComputedStyle(on).borderTopColor === values.accent, background: getComputedStyle(on).backgroundColor === values.soft,
|
||||
added: dd('.stat.added') === values.green, bad: dd('.stat.bad') === values.red, warn: dd('.stat.warn') === values.warning };
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
const EXPECTED_JOB_VIEW = {
|
||||
tree: MOCKUP_TREE, progress: MOCKUP_PROGRESS, results: MOCKUP_RESULTS, h2: ['Progress', 'Results'],
|
||||
summary: '● Scan log — raw detail, click to show', styles: MOCKUP_STYLES,
|
||||
pressed: { border: true, background: true, added: true, bad: true, warn: true },
|
||||
};
|
||||
|
||||
async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }) {
|
||||
await page.setViewportSize(viewport);
|
||||
const started = await request.post('/__test__/jobs/start', { data: { total: 19622 } });
|
||||
await expect(started).toBeOK();
|
||||
const { job } = await started.json();
|
||||
await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: { processed: 8240,
|
||||
counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 },
|
||||
records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } })).toBeOK();
|
||||
await openProduction(page, `activity/${job.id}`, theme);
|
||||
await expect(page.locator('#workspace-main .file-row')).toHaveCount(1);
|
||||
return job;
|
||||
}
|
||||
|
||||
for (const theme of THEMES) test(`job view follows the approved mockup ${theme}`, async ({ page, request }) => {
|
||||
await seedConcept(request);
|
||||
await openRunningJob(page, request, theme);
|
||||
expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW);
|
||||
|
||||
// Mutation check: a CSS change (tile gap) and a removed element (the outcome list) are both noticed.
|
||||
const undoCss = await mutate(page, { css: '#workspace-main .stat-row { gap: 3px; }' });
|
||||
expect(diffInventory(EXPECTED_JOB_VIEW, await page.evaluate(measureJobView)).map(item => item.path)).toEqual(['styles.statRow.columnGap']);
|
||||
await undoCss();
|
||||
const undoDom = await mutate(page, { remove: ['#workspace-main details.term'] });
|
||||
expect(await page.evaluate(() => [...document.querySelector('#workspace-main').children].map(node => node.localName)))
|
||||
.toEqual(['div', 'section', 'section']);
|
||||
await undoDom();
|
||||
expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW);
|
||||
});
|
||||
|
||||
test('job view fits a phone without page scroll', async ({ page, request }) => {
|
||||
await seedConcept(request);
|
||||
await openRunningJob(page, request, 'dark', { width: 390, height: 844 });
|
||||
const layout = await page.evaluate(() => {
|
||||
const main = document.querySelector('#workspace-main');
|
||||
const columns = node => getComputedStyle(node).gridTemplateColumns.split(' ').length;
|
||||
return {
|
||||
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
tiles: columns(main.querySelector('dl.stat-row')),
|
||||
outcomes: columns(main.querySelector('.outcomes')),
|
||||
inside: [...main.querySelectorAll('.stat, .outcome-list button, .file-row, .search-field')]
|
||||
.every(node => node.getBoundingClientRect().right <= main.getBoundingClientRect().right + 0.5),
|
||||
};
|
||||
});
|
||||
expect(layout).toEqual({ scroll: true, tiles: 2, outcomes: 1, inside: true });
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,
|
||||
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
|
||||
// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
|
||||
// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
|
||||
// columns, the 275px and 310px panel). The topbar and sidebar (D5, D28, D29) are the shell spec's.
|
||||
// columns, the 275px and 310px panel). The topbar and sidebar (D28, D29, D52, D53) are the shell spec's.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
@@ -149,12 +149,15 @@ function compareLoadingRow(reference,actual,count) {
|
||||
return diffInventory(unplaced(mapContinuation(reference,{loaded,total})),unplaced(actual));
|
||||
}
|
||||
|
||||
/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */
|
||||
/**
|
||||
* D15: the Import dialog's real operations, with the ruled intro and the Rescan wording. D55: the concept's own "Scan an
|
||||
* output folder → Review scan" row follows Rescan as "Scan for metadata", opening the concept's "Review metadata scan".
|
||||
*/
|
||||
function mapImportConcept(reference) {
|
||||
return {...reference,
|
||||
intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'},
|
||||
rows:reference.rows.filter(row=>row.title!=='Import portable recipes').map(row=>row.title==='Scan an output folder'
|
||||
?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
|
||||
rows:reference.rows.filter(row=>row.title!=='Import portable recipes').flatMap(row=>row.title==='Scan an output folder'
|
||||
?[{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']},{...row,title:'Scan for metadata'}]:[row])};
|
||||
}
|
||||
|
||||
const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll'];
|
||||
|
||||
@@ -14,6 +14,7 @@ from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_activity_fixture import register_activity_commands
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
|
||||
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
@@ -159,6 +160,7 @@ def create_app(root: Path) -> web.Application:
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
|
||||
async def canonical_asset(request: web.Request) -> web.Response:
|
||||
filename, content_type = CANONICAL_ASSETS[request.path]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept as openConceptPage,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
|
||||
|
||||
// Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the
|
||||
// concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes,
|
||||
@@ -16,7 +16,16 @@ const FULL_HEADING=new Set(['tags']);
|
||||
|
||||
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
|
||||
|
||||
const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity'));
|
||||
const removedItem=item=>String(item.className).includes('concept-badge');
|
||||
|
||||
/**
|
||||
* The concept with its Activity count hidden (D52): production counts running jobs only and hides the count at 0, as
|
||||
* the comparison fixture has none. The concept's own CSS then lays out its button without the count.
|
||||
*/
|
||||
async function openConcept(page,route,theme,motion) {
|
||||
await openConceptPage(page,route,theme,motion);
|
||||
await mutate(page,{css:'#activity-count { display: none }'});
|
||||
}
|
||||
|
||||
/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */
|
||||
function packedLefts(items,width,justify,gap) {
|
||||
@@ -37,10 +46,12 @@ function remainingTopActions(actions) {
|
||||
const gap=parseFloat(actions.columnGap);
|
||||
// A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items.
|
||||
const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1);
|
||||
// At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with
|
||||
// Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes.
|
||||
// At <=700px the concept spreads its shown items with `space-between`; the hidden pill (D53) takes no place.
|
||||
// Offsets come from the concept's own sizes.
|
||||
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
|
||||
return {...actions,height,width,
|
||||
// D52: the full-width row (<=700px) wraps its items when a zoomed phone is narrower than they are.
|
||||
flexWrap:actions.fullWidth?'wrap':actions.flexWrap,
|
||||
items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)};
|
||||
}
|
||||
|
||||
@@ -53,7 +64,7 @@ function remainingFooter(footer,spans) {
|
||||
}
|
||||
|
||||
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
|
||||
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
|
||||
// its `activity` pulse belongs to the Activity row, so the two entries never share one glyph.
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/**
|
||||
@@ -76,7 +87,9 @@ function mapShellConcept(reference) {
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
return {...nav,rows:[
|
||||
// D5: no Activity row until an activity center exists. D28: no Design review (prototype page).
|
||||
// D52: the Activity row counts running jobs only, so with none it has no count.
|
||||
{...rowNamed(nav,'Activity'),count:null},
|
||||
// D28: no Design review (prototype page).
|
||||
system,
|
||||
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
|
||||
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
|
||||
@@ -95,9 +108,11 @@ function mapShellConcept(reference) {
|
||||
small:'Your library, on your server',
|
||||
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
|
||||
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
|
||||
// D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items'
|
||||
// centered offsets follow from the concept's own item sizes.
|
||||
topActions:remainingTopActions(chrome.topActions),
|
||||
// D29: no "Interactive concept" badge. D53: the progress pill before the Activity button, hidden with no running
|
||||
// job. D52: the button's count is hidden at 0. The row's size and the remaining items' centered offsets follow
|
||||
// from the concept's own item sizes.
|
||||
topActions:remainingTopActions({...chrome.topActions,items:chrome.topActions.items.flatMap(item=>
|
||||
item.tag==='button'&&item.text==='Activity'?[{tag:'a',className:'activity-pill',shown:false},{...item,count:'0'}]:[item])}),
|
||||
footer:(()=>{
|
||||
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
|
||||
// (a prototype review page); the footer then holds only its span.
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
// Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
|
||||
// (index.html 51–52) and sidebar row (app.js ~341), with the recorded D52 (counts of running jobs), D53 (the progress
|
||||
// pill) and D54 (real job items). Fake jobs come from tests/browser/workspace_activity_fixture.py.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const pill = page => page.locator('.top-actions #activity-pill');
|
||||
const activityButton = page => page.locator('.top-actions button#activity-open');
|
||||
const topCount = page => page.locator('#activity-count');
|
||||
const navRow = page => page.locator('#system-nav a[data-route="activity"]');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const items = page => main(page).locator('section.surface > .activity-item');
|
||||
const LOST = 'This job ended when ComfyUI restarted.';
|
||||
// The job view (Task 3, D56): the page heading, then the Progress and Results surfaces and the Scan log.
|
||||
const heading = page => main(page).locator(':scope > .heading');
|
||||
const progressSection = page => main(page).getByRole('region', { name: 'Progress', exact: true });
|
||||
const resultsSection = page => main(page).getByRole('region', { name: 'Results', exact: true });
|
||||
const tiles = page => progressSection(page).locator('dl.stat-row > div.stat');
|
||||
const outcomeButtons = page => resultsSection(page).locator('.outcome-list > button');
|
||||
const fileRows = page => resultsSection(page).locator('.outcome-files > .file-row');
|
||||
const resultsNote = page => resultsSection(page).locator('.outcome-files > p.small');
|
||||
const filter = page => resultsSection(page).getByRole('searchbox', { name: 'Filter results', exact: true });
|
||||
const scanLog = page => main(page).locator('details.term');
|
||||
const TILE_LABELS = ['Added to Library', 'Already known', 'Needs review', 'No usable metadata', 'Errors'];
|
||||
const VIEWER_HINT = 'each row opens the image in the viewer';
|
||||
|
||||
async function tileValues(page) {
|
||||
return tiles(page).evaluateAll(nodes => nodes.map(node => [node.className, node.querySelector('dt').textContent,
|
||||
node.querySelector('dd').textContent]));
|
||||
}
|
||||
|
||||
async function openJob(page, id) {
|
||||
await page.goto(`${workspace}#/activity/${id}`);
|
||||
await expect(progressSection(page)).toBeVisible();
|
||||
}
|
||||
|
||||
async function startJob(request, data = {}) {
|
||||
const response = await request.post('/__test__/jobs/start', { data: { total: 19622, ...data } });
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).job;
|
||||
}
|
||||
|
||||
async function step(request, id, data) {
|
||||
await expect(await request.post(`/__test__/jobs/${id}/step`, { data })).toBeOK();
|
||||
}
|
||||
|
||||
async function finish(request, id, state = 'finished') {
|
||||
const response = await request.post(`/__test__/jobs/${id}/finish`, { data: { state } });
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).job;
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get('/prompt_manager/jobs');
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).jobs;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.activityErrors = [];
|
||||
page.on('pageerror', error => page.activityErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
// The store's own reads can meet a route handler at teardown; that must not fail the test it outlived.
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.activityErrors).toEqual([]);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
});
|
||||
|
||||
/** The concept's item structure and the computed styles its CSS gives it, measured the same way in both documents. */
|
||||
function measureItems(selector) {
|
||||
return [...document.querySelectorAll(selector)].map(item => {
|
||||
const css = (node, ...keys) => node && Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
|
||||
const tree = node => ({ tag: node.localName, className: [...node.classList].sort().join(' '), children: [...node.children].map(tree) });
|
||||
const progress = item.querySelector('progress');
|
||||
return {
|
||||
tree: tree(item),
|
||||
title: item.querySelector('h3').textContent,
|
||||
chip: item.querySelector('.chip').textContent,
|
||||
buttons: [...item.querySelectorAll('.row-actions button')].map(button => button.textContent),
|
||||
styles: {
|
||||
item: css(item, 'paddingTop', 'paddingBottom', 'borderBottomWidth', 'borderBottomStyle', 'borderBottomColor'),
|
||||
heading: css(item.querySelector('.panel-heading'), 'display', 'justifyContent', 'alignItems', 'columnGap'),
|
||||
h3: css(item.querySelector('h3'), 'fontSize', 'fontWeight', 'color', 'marginTop', 'marginBottom'),
|
||||
chip: css(item.querySelector('.chip'), 'fontSize', 'color', 'backgroundColor', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius'),
|
||||
p: css(item.querySelector('p'), 'fontSize', 'color', 'marginTop', 'lineHeight'),
|
||||
progress: progress && { ...css(progress, 'height', 'marginTop', 'marginBottom', 'accentColor'),
|
||||
fills: Math.abs(progress.getBoundingClientRect().width - item.getBoundingClientRect().width) < 1,
|
||||
max: progress.max, label: Boolean(progress.getAttribute('aria-label')) },
|
||||
actions: css(item.querySelector('.row-actions'), 'display', 'columnGap', 'marginTop'),
|
||||
button: css(item.querySelector('.row-actions button'), 'fontSize', 'paddingTop', 'paddingLeft', 'minHeight', 'borderTopLeftRadius'),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** D56: an item tree whose `h3` holds the link to the job's view. */
|
||||
function withTitleLink(tree) {
|
||||
const [heading, ...rest] = tree.children;
|
||||
const link = { tag: 'a', className: '', children: [] };
|
||||
return { ...tree, children: [{ ...heading, children: heading.children.map(node => node.tag === 'h3' ? { ...node, children: [link] } : node) }, ...rest] };
|
||||
}
|
||||
|
||||
function measurePage(mainSelector) {
|
||||
const root = document.querySelector(mainSelector);
|
||||
const section = root.querySelector(':scope > section.surface');
|
||||
return {
|
||||
h1: root.querySelector('.heading h1').textContent,
|
||||
description: root.querySelector('.heading p.muted').textContent,
|
||||
actions: [...root.querySelector('.heading .heading-actions').children].map(node => node.textContent),
|
||||
h2: section.querySelector(':scope > h2').textContent,
|
||||
surface: Object.fromEntries(['paddingTop', 'borderTopWidth', 'backgroundColor', 'borderTopLeftRadius']
|
||||
.map(key => [key, getComputedStyle(section)[key]])),
|
||||
};
|
||||
}
|
||||
|
||||
for (const theme of ['dark', 'light']) test(`activity page matches the concept list markup ${theme}`, async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await seedConcept(request);
|
||||
// The concept: its finished sample, then a running job started from System (startJob, app.js 940–952).
|
||||
await openConcept(page, 'activity', theme);
|
||||
const conceptPage = await page.evaluate(measurePage, '#main');
|
||||
const [conceptDone] = await page.evaluate(measureItems, '#main .activity-item');
|
||||
await page.goto('/__test__/canonical/workspace#system');
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await page.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
|
||||
await expect(page.locator('#main .activity-item progress')).toHaveCount(1);
|
||||
const [conceptRunning] = await page.evaluate(measureItems, '#main .activity-item');
|
||||
|
||||
const done = await startJob(request);
|
||||
await step(request, done.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
|
||||
await finish(request, done.id);
|
||||
const running = await startJob(request);
|
||||
await step(request, running.id, { processed: 8240 });
|
||||
await openProduction(page, 'activity', theme);
|
||||
await expect(items(page)).toHaveCount(2);
|
||||
const productionPage = await page.evaluate(measurePage, '#workspace-main');
|
||||
const [productionRunning, productionDone] = await page.evaluate(measureItems, '#workspace-main .activity-item');
|
||||
|
||||
// D52: no "Unified activity · proposed" chip (prototype copy); the rest of the heading and the section are the concept's.
|
||||
expect(conceptPage.actions).toEqual(['Unified activity · proposed']);
|
||||
expect(productionPage).toEqual({ ...conceptPage, actions: [] });
|
||||
// A completed job is the concept's completed item; D56: its title is a link to the job's view, in the title's colour.
|
||||
expect(conceptDone.title).toBe('✓ Library metadata enriched');
|
||||
expect(productionDone).toEqual({ ...conceptDone, tree: withTitleLink(conceptDone.tree), title: '✓ Scan for metadata' });
|
||||
const links = await items(page).locator('h3 > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'),
|
||||
getComputedStyle(node).color === getComputedStyle(node.parentElement).color, getComputedStyle(node).textDecorationLine]));
|
||||
expect(links).toEqual([[`#/activity/${running.id}`, true, 'none'], [`#/activity/${done.id}`, true, 'none']]);
|
||||
// D54: a running job's chip names its state ("Running", where the concept's sample reads "Needs attention"), and
|
||||
// its actions are Cancel job only (no "Simulate completion").
|
||||
expect(conceptRunning.buttons).toEqual(['Simulate completion', 'Cancel job']);
|
||||
const [, ...cancelOnly] = conceptRunning.tree.children.at(-1).children;
|
||||
const conceptActions = { ...conceptRunning.tree.children.at(-1), children: cancelOnly };
|
||||
expect(productionRunning).toEqual({ ...conceptRunning,
|
||||
tree: withTitleLink({ ...conceptRunning.tree, children: [...conceptRunning.tree.children.slice(0, -1), conceptActions] }),
|
||||
title: '○ Scan for metadata', chip: 'Running', buttons: ['Cancel job'] });
|
||||
await expect(items(page).nth(1).locator('p')).toHaveText(/^Finished \d\d:\d\d · 1,420 added · 18 errors$/);
|
||||
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('aria-label', 'Scan for metadata progress');
|
||||
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(41);
|
||||
});
|
||||
|
||||
test('pill shows while a job runs and opens its view', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
// D52: no running job, so neither count shows; the button is the concept's.
|
||||
await expect(activityButton(page)).toHaveAccessibleName('Activity');
|
||||
await expect(topCount(page)).toBeHidden();
|
||||
await expect(navRow(page)).toHaveText('Activity');
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
||||
await expect(pill(page)).toBeHidden();
|
||||
|
||||
const job = await startJob(request);
|
||||
// The list is read again on its own (15 s), or at once on a reload or when Activity opens.
|
||||
await page.reload();
|
||||
await expect(pill(page)).toBeVisible();
|
||||
await expect(pill(page)).toHaveText('Scanning · 0% · 0 / 19,622');
|
||||
await step(request, job.id, { processed: 8240 });
|
||||
const text = 'Scanning · 41% · 8,240 / 19,622';
|
||||
await expect(pill(page)).toHaveText(text);
|
||||
await expect(pill(page)).toHaveAttribute('aria-label', text);
|
||||
await expect(pill(page)).toHaveAttribute('href', `#/activity/${job.id}`);
|
||||
expect(await pill(page).locator('.activity-pill-bar i').evaluate(node => node.style.width)).toBe('41%');
|
||||
// The pill sits before the button, in the mockup's place, and both counts read 1.
|
||||
expect(await page.locator('.top-actions > *').evaluateAll(nodes => nodes.map(node => node.id || node.className)))
|
||||
.toEqual(['activity-pill', 'activity-open', 'theme-label', 'canvas-link']);
|
||||
await expect(topCount(page)).toBeVisible();
|
||||
await expect(topCount(page)).toHaveText('1');
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveText('1');
|
||||
|
||||
// The pill opens the job's own view (Task 3, D56), named by the job.
|
||||
await pill(page).click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Scan for metadata');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Activity');
|
||||
await expect(page).toHaveTitle('Scan for metadata · Prompt Manager');
|
||||
await expect(navRow(page)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 41);
|
||||
|
||||
// The Activity button opens the list itself from any page.
|
||||
await page.locator('nav [data-route="library"]').click();
|
||||
await activityButton(page).click();
|
||||
await expect(page).toHaveURL(/#\/activity$/);
|
||||
await expect(items(page)).toHaveCount(1);
|
||||
|
||||
await finish(request, job.id);
|
||||
await expect(pill(page)).toBeHidden();
|
||||
await expect(topCount(page)).toBeHidden();
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
||||
await expect(items(page).first().locator('.chip.green')).toHaveText('Completed');
|
||||
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
|
||||
// A finished job's title still opens its view (D56).
|
||||
await items(page).first().locator('h3 > a').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/activity/${job.id}$`));
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 0 added · 0 errors$/);
|
||||
});
|
||||
|
||||
test('a phone keeps the pill and its controls inside the topbar', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 8240 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(pill(page)).toHaveText('Scanning · 41% · 8,240 / 19,622');
|
||||
await expect(pill(page)).toHaveAttribute('aria-label', 'Scanning · 41% · 8,240 / 19,622');
|
||||
await expect(pill(page).locator('.activity-pill-files, .activity-pill-bar')).toHaveCount(2);
|
||||
await expect(pill(page).locator('.activity-pill-files')).toBeHidden();
|
||||
const layout = await page.locator('.top-actions').evaluate(row => ({
|
||||
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
inside: [...row.children].filter(node => node.getBoundingClientRect().width)
|
||||
.every(node => node.getBoundingClientRect().right <= row.getBoundingClientRect().right + 0.5
|
||||
&& node.getBoundingClientRect().left >= row.getBoundingClientRect().left - 0.5),
|
||||
}));
|
||||
expect(layout).toEqual({ scroll: true, inside: true });
|
||||
});
|
||||
|
||||
test('reload during a scan reattaches to the running job', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 1000 });
|
||||
await page.goto(`${workspace}#/activity`);
|
||||
await expect(items(page)).toHaveCount(1);
|
||||
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
|
||||
await page.reload();
|
||||
await expect(pill(page)).toHaveText('Scanning · 5% · 1,000 / 19,622');
|
||||
// The reloaded page follows the same job live and started nothing.
|
||||
await step(request, job.id, { processed: 9811 });
|
||||
await expect(pill(page)).toHaveText('Scanning · 50% · 9,811 / 19,622');
|
||||
expect(await items(page).first().locator('progress').evaluate(node => node.value)).toBe(50);
|
||||
await expect(items(page)).toHaveCount(1);
|
||||
expect((await listed(request)).map(item => [item.id, item.state])).toEqual([[job.id, 'running']]);
|
||||
|
||||
// A second tab attaches to the same job and sees the same progress.
|
||||
const other = await page.context().newPage();
|
||||
await other.goto(`${workspace}#/activity`);
|
||||
await expect(pill(other)).toHaveText('Scanning · 50% · 9,811 / 19,622');
|
||||
await expect(other.locator('#workspace-main .activity-item')).toHaveCount(1);
|
||||
await other.close();
|
||||
expect((await listed(request)).length).toBe(1);
|
||||
|
||||
// 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 }).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();
|
||||
});
|
||||
|
||||
test('lost server clears the pill and marks jobs ended', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 4000 });
|
||||
await page.goto(`${workspace}#/activity`);
|
||||
await expect(pill(page)).toBeVisible();
|
||||
await expect(topCount(page)).toHaveText('1');
|
||||
const reads = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs') reads.push(Date.now()); });
|
||||
// ComfyUI restarts: the stream drops without a final event and the new registry is empty.
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(pill(page)).toBeHidden();
|
||||
await expect(topCount(page)).toBeHidden();
|
||||
await expect(items(page).first().locator('.chip.blue')).toHaveText('Ended');
|
||||
await expect(items(page).first().locator('p')).toHaveText(LOST);
|
||||
await expect(items(page).first().locator('progress, .row-actions')).toHaveCount(0);
|
||||
await expect(navRow(page).locator('.nav-count')).toHaveCount(0);
|
||||
// One re-read found the restart; there is no retry storm and no finish toast for a job that only vanished.
|
||||
await page.waitForTimeout(2500);
|
||||
expect(reads.length).toBeLessThanOrEqual(2);
|
||||
await expect(toast(page)).toHaveText('');
|
||||
|
||||
// A server that cannot be read at all also ends the running job, then is read again with a growing backoff.
|
||||
const second = await startJob(request);
|
||||
await page.reload();
|
||||
await expect(pill(page)).toBeVisible();
|
||||
const failed = [];
|
||||
await page.route('**/prompt_manager/jobs**', route => { failed.push(Date.now()); return route.abort(); });
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(pill(page)).toBeHidden();
|
||||
await expect(items(page).filter({ hasText: LOST })).toHaveCount(1);
|
||||
await page.waitForTimeout(3500);
|
||||
// 1 s, then 2 s: at most two re-reads in 3.5 s.
|
||||
expect(failed.length).toBeLessThanOrEqual(2);
|
||||
await page.unroute('**/prompt_manager/jobs**');
|
||||
expect(second.id).not.toBe(job.id);
|
||||
});
|
||||
|
||||
test('start and finish toasts', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(pill(page)).toBeVisible();
|
||||
await step(request, job.id, { processed: 19622, counters: { added: 1420, errors: 18 } });
|
||||
await finish(request, job.id);
|
||||
await expect(toast(page)).toHaveText('Scan finished · 1,420 added · 18 errors');
|
||||
await expect(pill(page)).toBeHidden();
|
||||
|
||||
const single = await startJob(request);
|
||||
await page.reload();
|
||||
await expect(pill(page)).toBeVisible();
|
||||
await step(request, single.id, { processed: 19622, counters: { added: 1, errors: 1 } });
|
||||
await finish(request, single.id, 'failed');
|
||||
await expect(toast(page)).toHaveText('Scan failed · 1 added · 1 error');
|
||||
|
||||
// The store's own start: the real metadata scan over this server's outputs, announced, followed and finished.
|
||||
await page.goto(`${workspace}#/activity`);
|
||||
const started = await page.evaluate(async () => {
|
||||
const { createActivityStore } = await import('/prompt_manager/js/workspace/activity-store.mjs');
|
||||
const controller = new AbortController();
|
||||
const store = createActivityStore({ api: window.ApiClient.createApiClient(), pollMs: 0,
|
||||
scope: { signal: controller.signal, cleanup: () => {} } });
|
||||
const result = await store.start('scan');
|
||||
// Read before the busy attempt: its list read may already see the small scan finished and toast that.
|
||||
const toast = document.getElementById('workspace-toast').textContent;
|
||||
const busy = await store.start('scan').catch(error => ({ error: error.message }));
|
||||
return { id: result.job.id, title: result.job.title, toast, busy: busy.busy?.id ?? null, message: busy.message ?? null };
|
||||
});
|
||||
expect(started.title).toBe('Scan for metadata');
|
||||
expect(started.toast).toBe('Scan started.');
|
||||
// A second start while the scan runs is refused and names it (unless the small fixture scan already ended).
|
||||
if (started.busy !== null) expect(started.busy).toBe(started.id);
|
||||
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
|
||||
await expect.poll(async () => (await listed(request)).find(item => item.id === started.id)?.state, { timeout: 15000 }).toBe('finished');
|
||||
});
|
||||
|
||||
// ── Task 3: Scan for metadata, its review dialog and the job view ─────────────────────────────
|
||||
|
||||
async function startFromImport(page) {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
const importDialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true });
|
||||
const row = importDialog.locator('.operation').filter({ hasText: 'Scan for metadata' });
|
||||
await row.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
const review = page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
|
||||
await expect(review).toBeVisible();
|
||||
await review.getByRole('button', { name: 'Start scan', exact: true }).click();
|
||||
await expect(review).toHaveCount(0);
|
||||
}
|
||||
|
||||
test('start scan creates a job, toasts, and opens its view', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const posted = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/scan') posted.push(item); });
|
||||
await startFromImport(page);
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
const id = page.url().split('/').at(-1);
|
||||
expect(posted).toHaveLength(1);
|
||||
// The fixture scan is small and may already have finished; the start toast came first either way.
|
||||
await expect(toast(page)).toHaveText(/^Scan (started\.|finished · \d+ added · \d+ errors?)$/);
|
||||
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
|
||||
await expect(heading(page).locator('h1')).toBeFocused();
|
||||
expect((await listed(request)).map(item => item.id)).toEqual([id]);
|
||||
await expect.poll(async () => (await listed(request))[0].state, { timeout: 15000 }).toBe('finished');
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · \d+ added · \d+ errors?$/);
|
||||
// The finished scan's tiles add up to the files it processed, and its log is there to open.
|
||||
const job = (await listed(request))[0];
|
||||
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');
|
||||
});
|
||||
|
||||
test('job view counters follow the mapping and update live', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 8240, counters: { added: 1420, known: 6511, review: 37, no_metadata: 254, errors: 18 } });
|
||||
await openJob(page, job.id);
|
||||
// The header: the title, the detail line, then Download report (Task 5) and Cancel scan (danger).
|
||||
await expect(heading(page).locator('h1')).toHaveText('Scan for metadata');
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Started \d\d:\d\d · 2 output folders · closing this view does not cancel it$/);
|
||||
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel scan']);
|
||||
await expect(heading(page).getByRole('button', { name: 'Download report', exact: true })).toBeDisabled();
|
||||
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveClass(/danger/);
|
||||
// Progress: the bar, the server's line and the five tiles in order and colour.
|
||||
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 8,240 of 19,622');
|
||||
await expect(progressSection(page).locator('progress')).toHaveAttribute('aria-label', 'Scan progress');
|
||||
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 41);
|
||||
expect(await tileValues(page)).toEqual([
|
||||
['stat added', 'Added to Library', '1,420'], ['stat', 'Already known', '6,511'], ['stat warn', 'Needs review', '37'],
|
||||
['stat', 'No usable metadata', '254'], ['stat bad', 'Errors', '18'],
|
||||
]);
|
||||
const colours = await tiles(page).evaluateAll(nodes => nodes.map(node => getComputedStyle(node.querySelector('dd')).color));
|
||||
const expected = await page.evaluate(() => ['--green', '--ca-text', '--warning', '--ca-text', '--red'].map(name => {
|
||||
const probe = document.createElement('span');
|
||||
probe.style.color = `var(${name})`;
|
||||
document.querySelector('#workspace-main').append(probe);
|
||||
const colour = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return colour;
|
||||
}));
|
||||
expect([colours[0], colours[2], colours[4]]).toEqual([expected[0], expected[2], expected[4]]);
|
||||
expect(colours[1]).toBe(colours[3]);
|
||||
// The outcome list follows the mockup's order with the same counts.
|
||||
await expect(outcomeButtons(page)).toHaveText(['Added to Library1,420', 'Needs review37', 'Errors18', 'No usable metadata254', 'Already known6,511']);
|
||||
|
||||
// Live: the next step updates the bar, the line and the tiles without a reload.
|
||||
await step(request, job.id, { processed: 9811, counters: { added: 1500, errors: 19 }, records: [
|
||||
{ group: 'added', path: 'live/new.png', detail: 'Added to the Library as “Live”' },
|
||||
] });
|
||||
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 9,811 of 19,622');
|
||||
await expect(progressSection(page).locator('progress')).toHaveJSProperty('value', 50);
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('1,500');
|
||||
await expect(tiles(page).last().locator('dd')).toHaveText('19');
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
// A focused row keeps its focus while later reads append rows.
|
||||
await fileRows(page).first().focus();
|
||||
await step(request, job.id, { records: [{ group: 'added', path: 'live/next.png', detail: 'Added' }] });
|
||||
await expect(fileRows(page)).toHaveCount(2);
|
||||
await expect(fileRows(page).first()).toBeFocused();
|
||||
// The log is collapsed by default and holds the raw timestamped lines.
|
||||
await expect(scanLog(page)).not.toHaveAttribute('open', '');
|
||||
await expect(scanLog(page).locator('summary')).toContainText('Scan log');
|
||||
await scanLog(page).locator('summary').click();
|
||||
await expect(scanLog(page).locator('pre')).toHaveAttribute('tabindex', '0');
|
||||
await expect(scanLog(page).locator('pre')).toHaveText(/^\d\d:\d\d:\d\d \+ live\/new\.png · Added to the Library as “Live”\n\d\d:\d\d:\d\d \+ live\/next\.png · Added$/);
|
||||
|
||||
// Finished: the header says so, Cancel scan is gone and the counters stay.
|
||||
await finish(request, job.id);
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 1,501 added · 19 errors$/);
|
||||
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']);
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('1,501');
|
||||
});
|
||||
|
||||
test('added rows open the viewer, error rows are plain', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
// The fixture's two listed files share a name in the output and input roots; the row's root label tells them apart.
|
||||
await step(request, job.id, { processed: 3, records: [
|
||||
{ group: 'added', path: 'shared/same.png', root: 'output', detail: 'Added to the Library as “Cat portrait”' },
|
||||
{ group: 'known', path: 'shared/same.png', root: 'input', detail: 'Already linked to “Dog portrait”' },
|
||||
{ group: 'errors', path: 'broken/truncated.png', root: 'output', detail: 'Error: the file could not be processed (see the server log)' },
|
||||
] });
|
||||
await openJob(page, job.id);
|
||||
await expect(outcomeButtons(page).first()).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
const added = fileRows(page).first();
|
||||
expect(await added.evaluate(node => node.localName)).toBe('button');
|
||||
await expect(added.locator('.path')).toHaveText('shared/same.png');
|
||||
await expect(added.locator('.why')).toHaveText('Added to the Library as “Cat portrait”');
|
||||
await expect(resultsNote(page)).toHaveText('Each row opens the image in the viewer.');
|
||||
await added.click();
|
||||
const viewer = page.locator('dialog#viewer-dialog[open]');
|
||||
await expect(viewer).toBeVisible();
|
||||
await expect(viewer.locator('#viewer-title')).toContainText('same.png');
|
||||
await expect(viewer.locator('#viewer-stage img').first()).toHaveAttribute('src', /[?&]root=0(&|$)/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer).toHaveCount(0);
|
||||
await expect(added).toBeFocused();
|
||||
|
||||
// Already known opens the input root's file.
|
||||
await outcomeButtons(page).filter({ hasText: 'Already known' }).click();
|
||||
await expect(outcomeButtons(page).filter({ hasText: 'Already known' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await fileRows(page).first().click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-stage img').first()).toHaveAttribute('src', /[?&]root=1(&|$)/);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Errors: plain text rows, no viewer hint.
|
||||
await outcomeButtons(page).filter({ hasText: 'Errors' }).click();
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('div');
|
||||
await expect(fileRows(page).first().locator('.path')).toHaveText('broken/truncated.png');
|
||||
await expect(resultsNote(page)).toHaveCount(0);
|
||||
// An empty group says so.
|
||||
await outcomeButtons(page).filter({ hasText: 'Needs review' }).click();
|
||||
await expect(resultsSection(page).locator('.outcome-files > p.empty-state')).toHaveText('No files in this group.');
|
||||
|
||||
// A listed row whose file has left the listing toasts instead of opening an empty viewer.
|
||||
await step(request, job.id, { records: [{ group: 'added', path: 'gone/missing.png', root: 'output', detail: 'Added' }] });
|
||||
await outcomeButtons(page).first().click();
|
||||
await expect(fileRows(page)).toHaveCount(2);
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 4, records: [
|
||||
{ group: 'added', path: 'CCC/portrait_0001.png', detail: 'Added' },
|
||||
{ group: 'added', path: 'CCC/portrait_0002.png', detail: 'Added' },
|
||||
{ group: 'added', path: 'forest/pines_0008.png', detail: 'Added' },
|
||||
{ group: 'errors', path: 'CCC/broken.png', detail: 'Error' },
|
||||
] });
|
||||
await openJob(page, job.id);
|
||||
await expect(fileRows(page)).toHaveCount(3);
|
||||
await expect(filter(page)).toHaveAttribute('placeholder', 'Filter by file path…');
|
||||
await filter(page).fill('ccc/');
|
||||
await expect(fileRows(page).locator('.path')).toHaveText(['CCC/portrait_0001.png', 'CCC/portrait_0002.png']);
|
||||
await expect(resultsNote(page)).toHaveText(`Showing 2 of 3 · ${VIEWER_HINT}`);
|
||||
// The filter stays while another outcome is chosen.
|
||||
await outcomeButtons(page).filter({ hasText: 'Errors' }).click();
|
||||
await expect(fileRows(page).locator('.path')).toHaveText(['CCC/broken.png']);
|
||||
await filter(page).fill('pines');
|
||||
await expect(fileRows(page)).toHaveCount(0);
|
||||
await expect(resultsSection(page).locator('.outcome-files > p.empty-state')).toHaveText('No listed file matches this filter.');
|
||||
await outcomeButtons(page).first().click();
|
||||
await expect(fileRows(page).locator('.path')).toHaveText(['forest/pines_0008.png']);
|
||||
await filter(page).fill('');
|
||||
await expect(fileRows(page)).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('job view renders 200-row cap note', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 260, bulk: { group: 'added', count: 250 }, records: [
|
||||
...Array.from({ length: 10 }, (_, index) => ({ group: 'no_metadata', path: `misc/plain_${index}.png`, detail: 'No prompt metadata in the file' })),
|
||||
] });
|
||||
await openJob(page, job.id);
|
||||
await expect(tiles(page).first().locator('dd')).toHaveText('250');
|
||||
await expect(fileRows(page)).toHaveCount(200);
|
||||
await expect(resultsNote(page)).toHaveText(`Showing 200 of 250 · ${VIEWER_HINT}`);
|
||||
await outcomeButtons(page).filter({ hasText: 'No usable metadata' }).click();
|
||||
await expect(fileRows(page)).toHaveCount(10);
|
||||
await expect(resultsNote(page)).toHaveCount(0);
|
||||
// Every listed row is in the page and it stays responsive: a filter answers at once.
|
||||
await outcomeButtons(page).first().click();
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 9, counters: { added: 7, errors: 2 } });
|
||||
await openJob(page, job.id);
|
||||
// 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');
|
||||
expect((await tileValues(page)).map(([, , value]) => value)).toEqual(['7', '0', '0', '0', '2']);
|
||||
expect((await listed(request))[0].state).toBe('cancelled');
|
||||
|
||||
// A failed cancel request keeps the button and says so.
|
||||
const second = await startJob(request);
|
||||
await openJob(page, second.id);
|
||||
await page.route('**/cancel', route => route.fulfill({ status: 500, json: { success: false, error: 'nope' } }));
|
||||
await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The job could not be cancelled. Try again.');
|
||||
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('busy start names the running job', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const running = await startJob(request, { title: 'Database maintenance', kind: 'maintenance' });
|
||||
await startFromImport(page);
|
||||
await expect(toast(page)).toHaveText('Another job is running: Database maintenance.');
|
||||
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".
|
||||
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel job']);
|
||||
});
|
||||
|
||||
test('an unknown or restarted job view says so', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/activity/${'0'.repeat(32)}`);
|
||||
await expect(main(page).locator('section.surface p.empty-state')).toHaveText('This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.');
|
||||
await expect(main(page).getByRole('link', { name: 'All activity', exact: true })).toHaveAttribute('href', '#/activity');
|
||||
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 5, records: [{ group: 'added', path: 'kept/row.png', detail: 'Added' }] });
|
||||
await openJob(page, job.id);
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(heading(page).locator('p.muted')).toHaveText(LOST);
|
||||
await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0);
|
||||
// What was read before the restart stays on the page.
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
await expect(pill(page)).toBeHidden();
|
||||
});
|
||||
|
||||
// Review M3: a finished job the server no longer lists is gone from this tab too, and its open view says so.
|
||||
test('a finished job the server pruned leaves its open view honest', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const job = await startJob(request);
|
||||
await step(request, job.id, { processed: 1, records: [{ group: 'added', path: 'kept/row.png', detail: 'Added' }] });
|
||||
await finish(request, job.id);
|
||||
await openJob(page, job.id);
|
||||
await expect(fileRows(page)).toHaveCount(1);
|
||||
await filter(page).focus();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
// The next periodic list read (15 s) no longer has the finished job.
|
||||
test.slow();
|
||||
await expect(main(page).locator('section.surface p.empty-state')).toHaveText('This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.', { timeout: 20000 });
|
||||
await expect(progressSection(page)).toHaveCount(0);
|
||||
await expect(heading(page).locator('h1')).toHaveText('Activity');
|
||||
await expect(heading(page).locator('h1')).toBeFocused();
|
||||
await expect(page).toHaveTitle('Activity · Prompt Manager');
|
||||
});
|
||||
@@ -2037,3 +2037,50 @@ test('Set category limits the field to the server length, shows the server count
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
expect(answers).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Activity-1 Task 3 (D55): Import → "Scan for metadata" → Review scan opens the concept's "Review metadata scan".
|
||||
const scanReview = page => page.getByRole('dialog', { name: 'Review metadata scan', exact: true });
|
||||
async function openScanReview(page) {
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
const row = importDialog(page).locator('.operation').filter({ has: page.getByRole('heading', { name: 'Scan for metadata', exact: true }) });
|
||||
await row.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||||
await expect(scanReview(page)).toBeVisible();
|
||||
}
|
||||
|
||||
test('scan for metadata opens the review dialog from Import', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// Rescan stays the quick refresh; the metadata scan is its own row under it.
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await expect(importDialog(page).locator('.operation h3')).toHaveText(['Inspect a local image', 'Scan an output folder', 'Scan for metadata']);
|
||||
await expect(importDialog(page).locator('.operation').nth(2).locator('p')).toHaveText('Discover and enrich existing files using the configured sources.');
|
||||
await page.keyboard.press('Escape');
|
||||
await openScanReview(page);
|
||||
const dialog = scanReview(page);
|
||||
await expect(dialog.locator('dl.meta-grid > div > dt')).toHaveText(['Sources', 'Scope']);
|
||||
await expect(dialog.locator('dl.meta-grid > div > dd')).toHaveText(['2 output folders', 'All files']);
|
||||
await expect(dialog.locator('#modal-body > p.small')).toHaveText('Enrich positive / negative associations, reuse canonical text and report unchanged files. Do not inflate run counts or recreate existing thumbnails.');
|
||||
await expect(dialog.locator('.notice h3')).toHaveText('Back up the database first (recommended)');
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Start scan']);
|
||||
await expect(dialog.getByRole('button', { name: 'Start scan', exact: true })).toHaveClass(/ca-primary/);
|
||||
|
||||
// Back up now downloads the existing backup, toasts, and keeps the dialog open for the scan.
|
||||
const download = page.waitForEvent('download');
|
||||
await dialog.getByRole('button', { name: 'Back up now', exact: true }).click();
|
||||
expect((await download).suggestedFilename()).toMatch(/^prompts_backup_\d{8}_\d{6}\.db$/);
|
||||
await expect(toast(page)).toHaveText('Database backup downloaded.');
|
||||
await expect(dialog).toBeVisible();
|
||||
// A failed backup says so and downloads nothing.
|
||||
await page.route('**/prompt_manager/backup', route => route.fulfill({ status: 500, json: { success: false, error: 'disk full' } }));
|
||||
await dialog.getByRole('button', { name: 'Back up now', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The database could not be backed up. Try again.');
|
||||
|
||||
// Cancel closes it and starts nothing.
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(scanReview(page)).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeFocused();
|
||||
const jobs = await (await request.get('/prompt_manager/jobs')).json();
|
||||
expect(jobs.jobs).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const primary = ['images', 'library', 'tags', 'inspect'];
|
||||
const tools = ['system', 'overview'];
|
||||
const tools = ['activity', 'system', 'overview'];
|
||||
|
||||
async function noOverflow(page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
@@ -556,7 +556,7 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const icons = await page.evaluate(async () => {
|
||||
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
|
||||
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
|
||||
const names = ['images', 'library', 'tags', 'upload', 'overview', 'activity', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
|
||||
const otherDocument = document.implementation.createHTMLDocument();
|
||||
const rendered = names.map(name => {
|
||||
const icon = createWorkspaceIcon(name, otherDocument);
|
||||
@@ -575,7 +575,8 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
expect(icon).toMatchObject({ svg: true, className: 'icon', owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
|
||||
expect(icon.path.length).toBeGreaterThan(10);
|
||||
}
|
||||
// D28 ruling: Overview's own outline home glyph, not the concept's `activity` pulse ('M3 12h4l3-8 4 16 3-8h4').
|
||||
// D28 ruling: Overview's own outline home glyph; the concept's `activity` pulse is the Activity row's.
|
||||
expect(icons.rendered.find(icon => icon.name === 'activity').path).toBe('M3 12h4l3-8 4 16 3-8h4');
|
||||
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6');
|
||||
expect(new Set(icons.rendered.map(icon => icon.path)).size).toBe(icons.rendered.length);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ const routes = [
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
// #249 Activity-1: the concept's Activity page in the tools-and-system group.
|
||||
['activity', 'Activity', null],
|
||||
];
|
||||
|
||||
async function countThemeWrites(page) {
|
||||
@@ -46,6 +48,8 @@ test.beforeEach(async ({ page, request, browserName }) => {
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
// A route handler still running at teardown must not fail the test it outlived.
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.workspaceErrors).toEqual([]);
|
||||
});
|
||||
@@ -176,7 +180,8 @@ test('theme System follows ComfyUI and persists an explicit light or dark prefer
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
});
|
||||
|
||||
// Task A4 (D4, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity.
|
||||
// Task A4 (D4, D29): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge; Activity
|
||||
// (D52, D53) shows its button, and its pill only while a job runs.
|
||||
test('topbar matches the concept actions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
@@ -204,7 +209,7 @@ test('topbar matches the concept actions', async ({ page }) => {
|
||||
expect(shape).toEqual({
|
||||
children: ['div.breadcrumbs', 'div.top-actions'],
|
||||
crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'],
|
||||
actions: ['label.theme-label', 'a.canvas-link'],
|
||||
actions: ['a.activity-pill', 'button.', 'label.theme-label', 'a.canvas-link'],
|
||||
label: 'Theme', labelFor: 'theme',
|
||||
options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']],
|
||||
arrow: [['↗', 'true']],
|
||||
@@ -218,11 +223,12 @@ test('topbar matches the concept actions', async ({ page }) => {
|
||||
await expect(link).toHaveAttribute('href', '/');
|
||||
expect(await link.evaluate(node => node.href === `${location.origin}/`)).toBe(true);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Activity/ })).toHaveCount(0);
|
||||
await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Activity', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#activity-pill')).toBeHidden();
|
||||
await expect(page.locator('.concept-badge')).toHaveCount(0);
|
||||
await page.locator('nav [data-route="library"]').click();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
|
||||
await expect(topbar.locator('.top-actions > *')).toHaveCount(2);
|
||||
await expect(topbar.locator('.top-actions > *')).toHaveCount(4);
|
||||
});
|
||||
|
||||
// Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme
|
||||
@@ -242,13 +248,18 @@ test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) =>
|
||||
expected: bar.width - 28,
|
||||
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
|
||||
select: getComputedStyle(node.querySelector('#theme')).fontSize,
|
||||
labelLeft: box(node.querySelector('.theme-label')).left - bar.left,
|
||||
activityLeft: box(node.querySelector('#activity-open')).left - bar.left,
|
||||
actionsGap: [box(node.querySelector('#activity-open')).right, box(node.querySelector('.theme-label')).left,
|
||||
box(node.querySelector('.theme-label')).right, box(node.querySelector('.canvas-link')).left],
|
||||
linkRight: bar.right - box(node.querySelector('.canvas-link')).right,
|
||||
};
|
||||
});
|
||||
expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' });
|
||||
expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1);
|
||||
// The concept's three items spread with space-between: Activity first, Theme between equal gaps, ComfyUI last.
|
||||
expect(Math.abs(layout.activityLeft - 14)).toBeLessThanOrEqual(1);
|
||||
const [activityRight, themeLeft, themeRight, linkLeft] = layout.actionsGap;
|
||||
expect(Math.abs((themeLeft - activityRight) - (linkLeft - themeRight))).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.linkRight - 14)).toBeLessThanOrEqual(1);
|
||||
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toBeVisible();
|
||||
await theme.selectOption('light');
|
||||
@@ -506,18 +517,18 @@ test('a slow Images count does not delay the Library and Tags badges', async ({
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
});
|
||||
|
||||
test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => {
|
||||
test('sidebar bottom holds Activity, System and Overview with a neutral connection', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system`);
|
||||
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
|
||||
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
|
||||
const system = page.locator('nav#system-nav');
|
||||
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
|
||||
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
|
||||
['#/system', 'System'], ['#/overview', 'Overview'],
|
||||
['#/activity', 'Activity'], ['#/system', 'System'], ['#/overview', 'Overview'],
|
||||
]);
|
||||
await expect(system.locator('.nav-count')).toHaveCount(0);
|
||||
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(system.locator('svg.icon')).toHaveCount(2);
|
||||
await expect(system.locator('svg.icon')).toHaveCount(3);
|
||||
const connection = bottom.locator('.connection');
|
||||
expect(await childShape(connection)).toEqual(['i', 'div']);
|
||||
await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server');
|
||||
@@ -679,8 +690,14 @@ async function serveUnderProxyMount(page) {
|
||||
await page.route(url => url.pathname.startsWith(`${PROXY_MOUNT}/`), async route => {
|
||||
const url = new URL(route.request().url());
|
||||
url.pathname = url.pathname.slice(PROXY_MOUNT.length);
|
||||
const response = await route.fetch({ url: url.href });
|
||||
await route.fulfill({ response });
|
||||
// A request still in flight when the test ends (a thumbnail the last route started) meets a page that is
|
||||
// closing: its fetched response is disposed under it. Only that teardown error is ignored.
|
||||
try {
|
||||
const response = await route.fetch({ url: url.href });
|
||||
await route.fulfill({ response });
|
||||
} catch (error) {
|
||||
if (!/has been disposed|has been closed|Target page, context or browser has been closed/.test(String(error?.message))) throw error;
|
||||
}
|
||||
});
|
||||
return escaped;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Fake Activity jobs for the browser tests (#249 Activity-1).
|
||||
|
||||
A fake job is a real registry job whose runner waits for commands instead of
|
||||
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, 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;
|
||||
- ``POST /__test__/jobs/{id}/finish`` ends it as ``finished``, ``cancelled``
|
||||
or ``failed``; a cancel through the real route ends it ``cancelled``;
|
||||
- ``POST /__test__/jobs/restart`` acts out a ComfyUI restart: the open job
|
||||
streams drop without a final event and the registry starts empty.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
EVENTS_PATH = re.compile(r"^/prompt_manager/jobs/[0-9a-f]{32}/events$")
|
||||
FINAL_STATES = {"finished", "cancelled", "failed"}
|
||||
POLL_SECONDS = 0.02
|
||||
|
||||
|
||||
PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"}
|
||||
PREFIXES["no_metadata"] = "-"
|
||||
ROOT_INDEXES = {"output": 0, "input": 1}
|
||||
|
||||
|
||||
def outcome_records(data):
|
||||
"""The step's file outcomes: its ``records``, then its ``bulk`` rows."""
|
||||
records = data.get("records", [])
|
||||
bulk = data.get("bulk")
|
||||
if not isinstance(records, list):
|
||||
raise web.HTTPBadRequest()
|
||||
rows = []
|
||||
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": 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", "")),
|
||||
}
|
||||
)
|
||||
if bulk is not None:
|
||||
if not isinstance(bulk, dict) or bulk.get("group") not in PREFIXES:
|
||||
raise web.HTTPBadRequest()
|
||||
total = bulk.get("count")
|
||||
if isinstance(total, bool) or not isinstance(total, int) or total < 0:
|
||||
raise web.HTTPBadRequest()
|
||||
rows.extend(
|
||||
{
|
||||
"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)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def register_activity_commands(app, routes, api):
|
||||
from py.api.jobs import SCAN_GROUP_NAMES, JobBusy, JobRegistry
|
||||
|
||||
streams = set()
|
||||
endings = {}
|
||||
|
||||
@web.middleware
|
||||
async def track_streams(request, handler):
|
||||
if not EVENTS_PATH.match(request.path):
|
||||
return await handler(request)
|
||||
transport = request.transport
|
||||
streams.add(transport)
|
||||
try:
|
||||
return await handler(request)
|
||||
finally:
|
||||
streams.discard(transport)
|
||||
|
||||
app.middlewares.append(track_streams)
|
||||
|
||||
def fake_runner(ending):
|
||||
async def run(job):
|
||||
while not ending.done() and not job.cancelled:
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
state = ending.result() if ending.done() else "cancelled"
|
||||
job.publish({"type": "complete", "progress": job.progress})
|
||||
job.finish(state)
|
||||
|
||||
return run
|
||||
|
||||
def job_or_404(request):
|
||||
job = api._jobs.get(request.match_info["job_id"])
|
||||
if job is None or job.id not in endings:
|
||||
raise web.HTTPNotFound()
|
||||
return job
|
||||
|
||||
async def body(request):
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict):
|
||||
raise web.HTTPBadRequest()
|
||||
return data
|
||||
|
||||
def count(value, default=0):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise web.HTTPBadRequest()
|
||||
return value
|
||||
|
||||
@routes.post("/__test__/jobs/start")
|
||||
async def start_job(request):
|
||||
data = await body(request)
|
||||
ending = asyncio.get_running_loop().create_future()
|
||||
try:
|
||||
job = api._jobs.start(
|
||||
str(data.get("kind", "scan")),
|
||||
str(data.get("title", "Scan for metadata")),
|
||||
record_changing=True,
|
||||
scope={
|
||||
"mode": "all",
|
||||
"output_folders": count(data.get("output_folders"), 2),
|
||||
},
|
||||
runner=fake_runner(ending),
|
||||
groups=SCAN_GROUP_NAMES,
|
||||
)
|
||||
except JobBusy:
|
||||
raise web.HTTPConflict()
|
||||
job.update(total=count(data.get("total")))
|
||||
endings[job.id] = ending
|
||||
return web.json_response({"success": True, "job": job.summary()})
|
||||
|
||||
@routes.post("/__test__/jobs/{job_id}/step")
|
||||
async def step_job(request):
|
||||
job = job_or_404(request)
|
||||
data = await body(request)
|
||||
total = count(data.get("total"), job.total)
|
||||
processed = count(data.get("processed"), job.processed)
|
||||
counters = data.get("counters", {})
|
||||
if not isinstance(counters, dict) or set(counters) - set(job.counters):
|
||||
raise web.HTTPBadRequest()
|
||||
for record in outcome_records(data):
|
||||
group = record["group"]
|
||||
path = record["path"]
|
||||
detail = record["detail"]
|
||||
job.record(group, record, f"{PREFIXES[group]} {path} · {detail}")
|
||||
for key, value in counters.items():
|
||||
job.counters[key] = count(value)
|
||||
progress = processed * 100 // total if total else 0
|
||||
job.update(
|
||||
progress=progress,
|
||||
processed=processed,
|
||||
total=total,
|
||||
status_line=f"Processing file {processed:,} of {total:,}",
|
||||
)
|
||||
job.publish({"type": "progress", "progress": progress})
|
||||
return web.json_response({"success": True, "job": job.summary()})
|
||||
|
||||
@routes.post("/__test__/jobs/{job_id}/finish")
|
||||
async def finish_job(request):
|
||||
job = job_or_404(request)
|
||||
state = (await body(request)).get("state", "finished")
|
||||
if state not in FINAL_STATES:
|
||||
raise web.HTTPBadRequest()
|
||||
ending = endings[job.id]
|
||||
if not ending.done():
|
||||
ending.set_result(state)
|
||||
while job.running:
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
return web.json_response({"success": True, "job": job.summary()})
|
||||
|
||||
@routes.post("/__test__/jobs/restart")
|
||||
async def restart(request):
|
||||
old = api._jobs
|
||||
api._jobs = JobRegistry(api.logger)
|
||||
endings.clear()
|
||||
for transport in list(streams):
|
||||
transport.close()
|
||||
tasks = [job.task for job in old.list() if job.task and not job.task.done()]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return web.json_response({"success": True})
|
||||
@@ -0,0 +1,371 @@
|
||||
// Activity (#249 Activity-1): the session job store behind the top-bar button and pill and the Activity page.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/activity-store.mjs');
|
||||
|
||||
const ID_A = 'a'.repeat(32);
|
||||
const ID_B = 'b'.repeat(32);
|
||||
const ID_C = 'c'.repeat(32);
|
||||
|
||||
function summary(overrides = {}) {
|
||||
return { id: ID_A, kind: 'scan', title: 'Scan for metadata', state: 'running', started_at: 1000, finished_at: null,
|
||||
progress: 0, status_line: '', processed: 0, total: 0,
|
||||
counters: { added: 0, known: 0, review: 0, no_metadata: 0, errors: 0 }, scope: { mode: 'all', output_folders: 2 },
|
||||
...overrides };
|
||||
}
|
||||
|
||||
function apiError(status, message, code) {
|
||||
return Object.assign(new Error(message), { status, code });
|
||||
}
|
||||
|
||||
/** A fake world: the API's answers, every opened stream, the pending timers and the toasts. */
|
||||
function harness() {
|
||||
const world = { list: [], listError: null, posts: [], postResult: null, postError: null, streams: [], timers: [],
|
||||
toasts: [], gets: 0 };
|
||||
const cleanups = [];
|
||||
const controller = new AbortController();
|
||||
const scope = { signal: controller.signal, cleanup: fn => cleanups.push(fn) };
|
||||
const api = {
|
||||
async get(url, options) {
|
||||
assert.equal(url, '/prompt_manager/jobs');
|
||||
assert.equal(options.cache, 'no-store');
|
||||
world.gets += 1;
|
||||
if (world.listError) throw world.listError;
|
||||
return { success: true, jobs: world.list };
|
||||
},
|
||||
async post(url, body) {
|
||||
world.posts.push([url, body]);
|
||||
if (world.postError) throw world.postError;
|
||||
return world.postResult;
|
||||
},
|
||||
};
|
||||
const connect = url => {
|
||||
const stream = { url, closed: false, onmessage: null, onerror: null, close() { this.closed = true; } };
|
||||
world.streams.push(stream);
|
||||
return stream;
|
||||
};
|
||||
const timers = {
|
||||
setTimeout(fn, delay) { const timer = { fn, delay, cleared: false }; world.timers.push(timer); return timer; },
|
||||
clearTimeout(timer) { timer.cleared = true; },
|
||||
};
|
||||
world.dispose = () => { controller.abort(); cleanups.forEach(fn => fn()); };
|
||||
world.fire = async () => {
|
||||
const timer = world.timers.find(item => !item.cleared && !item.fired);
|
||||
timer.fired = true;
|
||||
timer.fn();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return timer.delay;
|
||||
};
|
||||
world.open = () => world.streams.filter(stream => !stream.closed);
|
||||
return { world, api, scope, connect, timers, pollMs: 0, notify: message => world.toasts.push(message) };
|
||||
}
|
||||
|
||||
const send = (stream, job) => stream.onmessage({ data: JSON.stringify(job) });
|
||||
|
||||
test('store merges list and SSE updates and caps backoff', async () => {
|
||||
const { createActivityStore, BACKOFF_MAX_MS, LOST_DETAIL } = await source;
|
||||
const { world, ...deps } = harness();
|
||||
world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900, counters: { added: 3, errors: 0 } })];
|
||||
const store = createActivityStore(deps);
|
||||
let changes = 0;
|
||||
store.subscribe(() => { changes += 1; });
|
||||
await store.load();
|
||||
assert.equal(store.status, 'ready');
|
||||
assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_A, 'running'], [ID_B, 'finished']]);
|
||||
assert.deepEqual(store.running().map(job => job.id), [ID_A]);
|
||||
// One stream per running job only; a finished job is not followed.
|
||||
assert.deepEqual(world.streams.map(stream => stream.url), [`/prompt_manager/jobs/${ID_A}/events`]);
|
||||
assert.ok(changes >= 1);
|
||||
|
||||
// An SSE summary replaces the listed one; garbage and another job's summary are ignored.
|
||||
const [stream] = world.streams;
|
||||
send(stream, summary({ progress: 42, processed: 8240, total: 19622 }));
|
||||
stream.onmessage({ data: 'not json' });
|
||||
send(stream, summary({ id: ID_B, progress: 99 }));
|
||||
assert.equal(store.find(ID_A).progress, 42);
|
||||
assert.equal(store.find(ID_B).state, 'finished');
|
||||
assert.deepEqual(world.toasts, []);
|
||||
|
||||
// The end of the job: the finish toast, and the stream is closed.
|
||||
send(stream, summary({ state: 'finished', progress: 100, finished_at: 2000, counters: { added: 1420, errors: 18 } }));
|
||||
assert.deepEqual(world.toasts, ['Scan finished · 1,420 added · 18 errors']);
|
||||
assert.equal(stream.closed, true);
|
||||
assert.deepEqual(store.running(), []);
|
||||
|
||||
// A new running job from another tab, then the server goes away: every re-read waits longer, up to 30 s.
|
||||
world.list = [summary({ id: ID_C, state: 'running' })];
|
||||
await store.load();
|
||||
const second = world.open()[0];
|
||||
assert.equal(second.url, `/prompt_manager/jobs/${ID_C}/events`);
|
||||
world.listError = new Error('connection refused');
|
||||
second.onerror();
|
||||
assert.equal(second.closed, true);
|
||||
const delays = [];
|
||||
for (let index = 0; index < 8; index += 1) delays.push(await world.fire());
|
||||
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
|
||||
assert.ok(delays.every(delay => delay <= BACKOFF_MAX_MS));
|
||||
// While the server cannot be read the running job shows as ended, so no pill stays.
|
||||
assert.equal(store.status, 'error');
|
||||
assert.equal(store.find(ID_C).state, 'lost');
|
||||
assert.deepEqual(store.running(), []);
|
||||
assert.equal(typeof LOST_DETAIL, 'string');
|
||||
|
||||
// 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']]);
|
||||
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();
|
||||
});
|
||||
|
||||
test('a dropped stream of a still running job reattaches after the backoff, then resets it', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, ...deps } = harness();
|
||||
world.list = [summary()];
|
||||
const store = createActivityStore(deps);
|
||||
await store.load();
|
||||
world.streams[0].onerror();
|
||||
assert.equal(await world.fire(), 1000);
|
||||
const again = world.open();
|
||||
assert.equal(again.length, 1);
|
||||
assert.equal(store.find(ID_A).state, 'running');
|
||||
again[0].onerror();
|
||||
assert.equal(await world.fire(), 2000);
|
||||
send(world.open()[0], summary({ progress: 5 }));
|
||||
world.open()[0].onerror();
|
||||
assert.equal(await world.fire(), 1000, 'a received message resets the backoff');
|
||||
// A second drop while a re-read is already scheduled does not schedule another.
|
||||
world.open()[0].onerror();
|
||||
store.load();
|
||||
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
world.dispose();
|
||||
assert.ok(world.streams.every(stream => stream.closed), 'dispose closes every stream');
|
||||
assert.ok(world.timers.every(timer => timer.cleared || timer.fired), 'dispose clears the pending re-read');
|
||||
});
|
||||
|
||||
test('start returns busy with the running job', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, ...deps } = harness();
|
||||
const store = createActivityStore(deps);
|
||||
world.postResult = { success: true, job: summary() };
|
||||
const started = await store.start('scan');
|
||||
assert.equal(started.job.id, ID_A);
|
||||
assert.deepEqual(world.posts, [['/prompt_manager/jobs/scan', {}]]);
|
||||
assert.deepEqual(world.toasts, ['Scan started.']);
|
||||
assert.equal(world.open().length, 1, 'the started job is followed');
|
||||
|
||||
world.postError = apiError(409, 'Scan for metadata is already running', 'busy');
|
||||
world.list = [summary()];
|
||||
const busy = await store.start('scan');
|
||||
assert.equal(busy.busy.id, ID_A);
|
||||
assert.equal(busy.busy.title, 'Scan for metadata');
|
||||
assert.equal(busy.message, 'Scan for metadata is already running');
|
||||
assert.equal(world.toasts.length, 1, 'a refused start has no start toast');
|
||||
|
||||
world.postError = apiError(500, 'boom', null);
|
||||
await assert.rejects(store.start('scan'), /boom/);
|
||||
world.postError = null;
|
||||
world.postResult = { success: true, job: { id: 'nope' } };
|
||||
await assert.rejects(store.start('scan'), /Invalid job/);
|
||||
|
||||
world.postResult = { success: true, cancellation_requested: true };
|
||||
assert.equal(await store.cancel(ID_A), true);
|
||||
assert.deepEqual(world.posts.at(-1), [`/prompt_manager/jobs/${ID_A}/cancel`, {}]);
|
||||
world.postResult = { success: true, cancellation_requested: false };
|
||||
assert.equal(await store.cancel(ID_A), false);
|
||||
world.dispose();
|
||||
});
|
||||
|
||||
test('a list that is not a job list counts as a failed read', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, api, ...deps } = harness();
|
||||
const store = createActivityStore({ ...deps, api: { ...api, get: async () => ({ success: true }) } });
|
||||
await store.load();
|
||||
assert.equal(store.status, 'error');
|
||||
assert.equal(world.timers.length, 1);
|
||||
world.dispose();
|
||||
await store.load();
|
||||
assert.equal(store.status, 'error', 'a disposed store reads nothing');
|
||||
});
|
||||
|
||||
test('a visible tab reads the list again every 15 s; a hidden one skips its turn', async () => {
|
||||
const { createActivityStore, POLL_MS } = await source;
|
||||
const { world, ...deps } = harness();
|
||||
let visible = true;
|
||||
const store = createActivityStore({ ...deps, pollMs: POLL_MS, isVisible: () => visible });
|
||||
await store.load();
|
||||
assert.equal(world.gets, 1);
|
||||
// A job started in another tab appears on the next read.
|
||||
world.list = [summary()];
|
||||
assert.equal(await world.fire(), 15000);
|
||||
assert.equal(world.gets, 2);
|
||||
assert.deepEqual(store.running().map(job => job.id), [ID_A]);
|
||||
visible = false;
|
||||
assert.equal(await world.fire(), 15000);
|
||||
assert.equal(world.gets, 2, 'a hidden tab does not read');
|
||||
visible = true;
|
||||
await world.fire();
|
||||
assert.equal(world.gets, 3);
|
||||
// A manual read replaces the pending poll rather than adding one.
|
||||
await store.load();
|
||||
assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 1);
|
||||
world.dispose();
|
||||
assert.ok(world.timers.every(timer => timer.cleared || timer.fired));
|
||||
});
|
||||
|
||||
test('a late or older answer never brings an ended job back, so it is announced once', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, api, ...deps } = harness();
|
||||
const answers = [];
|
||||
const slowApi = { ...api, get: () => new Promise(resolve => answers.push(resolve)) };
|
||||
const store = createActivityStore({ ...deps, api: slowApi });
|
||||
const first = store.load();
|
||||
const second = store.load();
|
||||
// The newer read answers first; the older one, answering later with stale data, is dropped.
|
||||
answers[1]({ success: true, jobs: [summary()] });
|
||||
await second;
|
||||
answers[0]({ success: true, jobs: [] });
|
||||
await first;
|
||||
assert.deepEqual(store.jobs().map(job => job.state), ['running']);
|
||||
send(world.open()[0], summary({ state: 'finished', counters: { added: 2, errors: 0 } }));
|
||||
assert.deepEqual(world.toasts, ['Scan finished · 2 added · 0 errors']);
|
||||
// A list read sent before the end still says running: the job stays finished and no stream reopens.
|
||||
const third = store.load();
|
||||
answers[2]({ success: true, jobs: [summary()] });
|
||||
await third;
|
||||
assert.equal(store.find(ID_A).state, 'finished');
|
||||
assert.equal(world.open().length, 0);
|
||||
assert.equal(world.toasts.length, 1);
|
||||
// A stale failure is dropped too.
|
||||
const failing = store.load();
|
||||
const newest = store.load();
|
||||
answers[4]({ success: true, jobs: [] });
|
||||
await newest;
|
||||
answers[3](Promise.reject(new Error('late failure')));
|
||||
await failing;
|
||||
assert.equal(store.status, 'ready');
|
||||
world.dispose();
|
||||
});
|
||||
|
||||
test('summaries are validated and the copy follows the spec', async () => {
|
||||
const { parseJob, finishToast, startToast, pillParts, outcomeLine, backoffDelay } = await source;
|
||||
for (const raw of [null, [], { id: 'x', state: 'running' }, summary({ state: 'lost' }), summary({ id: ID_A.toUpperCase() })]) {
|
||||
assert.equal(parseJob(raw), null);
|
||||
}
|
||||
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: {}, 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 } }));
|
||||
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');
|
||||
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');
|
||||
assert.equal(finishToast({ ...scan, counters: {}, state: 'finished' }), 'Scan finished · 0 added · 0 errors');
|
||||
const other = { ...scan, kind: 'backup', title: 'Backup' };
|
||||
assert.equal(finishToast({ ...other, state: 'finished' }), 'Backup finished.');
|
||||
assert.equal(finishToast({ ...other, state: 'cancelled' }), 'Backup was cancelled.');
|
||||
assert.equal(finishToast({ ...other, state: 'failed' }), 'Backup failed.');
|
||||
assert.equal(startToast(scan), 'Scan started.');
|
||||
assert.equal(startToast(other), 'Backup started.');
|
||||
assert.equal(outcomeLine(scan), '1 added · 1 error');
|
||||
assert.equal(outcomeLine(other), '');
|
||||
assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]);
|
||||
});
|
||||
|
||||
test('pagehide stops polling, closes streams and aborts the in-flight read; pageshow resumes', async () => {
|
||||
const { createActivityStore } = await source;
|
||||
const { world, api, ...deps } = harness();
|
||||
const lifecycle = new EventTarget();
|
||||
const signals = [];
|
||||
let answer = null;
|
||||
const pendingApi = { ...api, get: (url, options) => {
|
||||
signals.push(options.signal);
|
||||
world.gets += 1;
|
||||
if (world.gets === 1) return Promise.resolve({ success: true, jobs: [summary()] });
|
||||
return new Promise((resolve, reject) => {
|
||||
answer = resolve;
|
||||
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
|
||||
});
|
||||
} };
|
||||
const store = createActivityStore({ ...deps, api: pendingApi, pollMs: 15000, lifecycle });
|
||||
await store.load();
|
||||
assert.equal(world.open().length, 1);
|
||||
const poll = world.timers.find(timer => !timer.cleared && timer.delay === 15000);
|
||||
assert.ok(poll, 'a visible tab polls');
|
||||
// A read in flight when the page goes away (a closed tab, a test teardown) is aborted, nothing is scheduled again.
|
||||
const read = store.load();
|
||||
lifecycle.dispatchEvent(new Event('pagehide'));
|
||||
await read;
|
||||
assert.equal(signals.at(-1).aborted, true);
|
||||
assert.equal(world.open().length, 0);
|
||||
assert.ok(world.timers.every(timer => timer.cleared || timer.fired));
|
||||
assert.equal(store.find(ID_A).state, 'running', 'a hidden page marks nothing lost');
|
||||
await store.load();
|
||||
assert.equal(world.gets, 2, 'a suspended store reads nothing');
|
||||
// Back from the cache, it reads the list again and follows the job.
|
||||
const resumed = Object.assign(new Event('pageshow'), { persisted: true });
|
||||
lifecycle.dispatchEvent(resumed);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(world.gets, 3);
|
||||
answer({ success: true, jobs: [summary()] });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(world.open().length, 1);
|
||||
world.dispose();
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -4,10 +4,10 @@ const source = import('../../web/js/workspace/state.mjs');
|
||||
|
||||
test('workspace routes resolve only supported deep links', async () => {
|
||||
const { resolveRoute } = await source;
|
||||
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system']) {
|
||||
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system', 'activity']) {
|
||||
assert.equal(resolveRoute(`#/${route}`).id, route);
|
||||
}
|
||||
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) {
|
||||
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>', '#/activity/abc', `#/activity/${'A'.repeat(32)}`]) {
|
||||
assert.equal(resolveRoute(hash).id, 'images');
|
||||
}
|
||||
});
|
||||
@@ -23,10 +23,33 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
|
||||
assert.equal(routeTitle(route), route.label);
|
||||
}
|
||||
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
|
||||
for (const id of ['images', 'library', 'tags', 'overview']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
for (const id of ['images', 'library', 'tags', 'overview', 'activity']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
for (const id of ['inspect', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), true, id);
|
||||
});
|
||||
|
||||
test('a job of the Activity page is its own deep link', async () => {
|
||||
const { resolveRoute, routeTitle, isHandoff } = await source;
|
||||
const id = '0123456789abcdef'.repeat(2);
|
||||
const route = resolveRoute(`#/activity/${id}`);
|
||||
assert.deepEqual([route.id, route.jobId, route.hash, routeTitle(route), isHandoff(route)],
|
||||
['activity', id, `#/activity/${id}`, 'Activity', false]);
|
||||
assert.equal(resolveRoute('#/activity').jobId, undefined);
|
||||
assert.equal(route.href, undefined, 'Activity has no legacy card on Overview');
|
||||
// The router renders again when only the job changes, and not for the same job.
|
||||
const { mountRouter } = await source;
|
||||
const other = 'f'.repeat(32);
|
||||
const win = new EventTarget();
|
||||
win.location = { hash: `#/activity/${id}` };
|
||||
win.history = { state: null, replaceState(state, unused, hash) { win.location.hash = hash; } };
|
||||
const seen = [];
|
||||
const dispose = mountRouter(win, next => seen.push([next.id, next.jobId]));
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash = `#/activity/${other}`; win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash = '#/activity'; win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen, [['activity', id], ['activity', other], ['activity', undefined]]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
|
||||
const { summarizeStats } = await source;
|
||||
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// The shell's shared toast: plain messages clear after 6.5 s; a message with a link stays 20 s (D57), and any toast
|
||||
// stays while the pointer or keyboard focus rests on it.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/toast.mjs');
|
||||
|
||||
class FakeToast extends EventTarget {
|
||||
constructor() { super(); this.held = false; this.nodes = []; this.text = ''; }
|
||||
get textContent() { return this.text; }
|
||||
set textContent(value) { this.text = value; this.nodes = []; }
|
||||
replaceChildren(...nodes) { this.nodes = nodes; this.text = nodes.map(node => node.textContent).join(''); }
|
||||
matches(selector) { assert.equal(selector, ':hover, :focus-within'); return this.held; }
|
||||
}
|
||||
|
||||
function install() {
|
||||
const toast = new FakeToast();
|
||||
const previous = global.document;
|
||||
global.document = {
|
||||
getElementById: id => id === 'workspace-toast' ? toast : null,
|
||||
createTextNode: text => ({ textContent: text }),
|
||||
createElement: tag => ({ tag, href: '', textContent: '' }),
|
||||
};
|
||||
return { toast, restore: () => { global.document = previous; } };
|
||||
}
|
||||
|
||||
test('a plain toast clears after 6.5 s and a linked one after 20 s', async t => {
|
||||
const { showWorkspaceToast, TOAST_MS, LINK_TOAST_MS } = await source;
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { toast, restore } = install();
|
||||
try {
|
||||
showWorkspaceToast('Scan started.');
|
||||
assert.equal(toast.textContent, 'Scan started.');
|
||||
t.mock.timers.tick(TOAST_MS);
|
||||
assert.equal(toast.textContent, '');
|
||||
|
||||
showWorkspaceToast(['Another job is running: ', { text: 'Scan', href: '#/activity/x' }, '.']);
|
||||
assert.equal(toast.textContent, 'Another job is running: Scan.');
|
||||
assert.deepEqual(toast.nodes[1], { tag: 'a', href: '#/activity/x', textContent: 'Scan' });
|
||||
t.mock.timers.tick(TOAST_MS);
|
||||
assert.equal(toast.textContent, 'Another job is running: Scan.');
|
||||
t.mock.timers.tick(LINK_TOAST_MS - TOAST_MS);
|
||||
assert.equal(toast.textContent, '');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a toast under the pointer or focus waits until they leave', async t => {
|
||||
const { showWorkspaceToast, TOAST_MS } = await source;
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { toast, restore } = install();
|
||||
try {
|
||||
showWorkspaceToast('Prompt copied.');
|
||||
toast.held = true;
|
||||
t.mock.timers.tick(TOAST_MS);
|
||||
assert.equal(toast.textContent, 'Prompt copied.', 'held: it stays');
|
||||
// Focus moving inside the toast keeps it; leaving it starts a fresh 6.5 s.
|
||||
toast.dispatchEvent(new Event('focusout'));
|
||||
t.mock.timers.tick(0);
|
||||
toast.dispatchEvent(new Event('pointerleave'));
|
||||
assert.equal(toast.textContent, 'Prompt copied.');
|
||||
toast.held = false;
|
||||
toast.dispatchEvent(new Event('focusout'));
|
||||
t.mock.timers.tick(0);
|
||||
t.mock.timers.tick(TOAST_MS - 1);
|
||||
assert.equal(toast.textContent, 'Prompt copied.');
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(toast.textContent, '');
|
||||
|
||||
// A newer toast replaces a held one and stops waiting for the old one's pointer.
|
||||
showWorkspaceToast('First.');
|
||||
toast.held = true;
|
||||
t.mock.timers.tick(TOAST_MS);
|
||||
showWorkspaceToast('Second.');
|
||||
toast.held = false;
|
||||
toast.dispatchEvent(new Event('pointerleave'));
|
||||
t.mock.timers.tick(TOAST_MS - 1);
|
||||
assert.equal(toast.textContent, 'Second.');
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(toast.textContent, '');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a page without the toast shows nothing and does not fail', async () => {
|
||||
const { showWorkspaceToast } = await source;
|
||||
const previous = global.document;
|
||||
global.document = { getElementById: () => null };
|
||||
try {
|
||||
assert.doesNotThrow(() => showWorkspaceToast('Nothing.'));
|
||||
} finally {
|
||||
global.document = previous;
|
||||
}
|
||||
});
|
||||
@@ -1965,6 +1965,353 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
|
||||
self.assertEqual(data["last_event"]["type"], "error")
|
||||
|
||||
|
||||
class TestActivityJobs(AdminAPITestCase):
|
||||
"""The scan runs as a registry job; legacy routes keep their payloads."""
|
||||
|
||||
def _gate_collect(self):
|
||||
import threading
|
||||
|
||||
gate = threading.Event()
|
||||
calls = []
|
||||
original = self.api._collect_output_media_sync
|
||||
|
||||
def gated(output_dirs):
|
||||
calls.append(output_dirs)
|
||||
gate.wait(5)
|
||||
return original(output_dirs)
|
||||
|
||||
self.api._collect_output_media_sync = gated
|
||||
return gate, calls
|
||||
|
||||
async def _json(self, method, url):
|
||||
resp = await self.client.request(method, url)
|
||||
return resp.status, await resp.json()
|
||||
|
||||
async def test_legacy_scan_route_reuses_registry_job_and_payload_shape(self):
|
||||
from py.api.admin import SCAN_COUNT_KEYS
|
||||
|
||||
_write_png(self.output_dir / "gen.png", prompt_text="a red square")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
gate, calls = self._gate_collect()
|
||||
|
||||
# The workspace starts the job; the legacy page then attaches to it.
|
||||
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
self.assertEqual(status, 202)
|
||||
self.assertTrue(started["success"])
|
||||
legacy = asyncio.ensure_future(
|
||||
self.client.request("POST", "/prompt_manager/scan", data="{}")
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
gate.set()
|
||||
body = await (await legacy).text()
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
payloads = _sse_payloads(body)
|
||||
progress = [p for p in payloads if p["type"] == "progress"]
|
||||
self.assertTrue(progress)
|
||||
legacy_keys = set(SCAN_COUNT_KEYS) | {"type", "progress", "status", "processed"}
|
||||
for payload in progress:
|
||||
self.assertEqual(set(payload), legacy_keys)
|
||||
final = payloads[-1]
|
||||
self.assertEqual(final["type"], "complete")
|
||||
self.assertEqual(set(final), set(SCAN_COUNT_KEYS) | {"type", "directories"})
|
||||
self.assertEqual(final["added"], 1)
|
||||
self.assertEqual(final["directories"], ["output"])
|
||||
|
||||
status, listed = await self._json("GET", "/prompt_manager/jobs")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(listed["jobs"]), 1)
|
||||
job = listed["jobs"][0]
|
||||
self.assertEqual(job["id"], started["job"]["id"])
|
||||
self.assertEqual(job["id"], self.api._scan_job.id)
|
||||
self.assertEqual((job["kind"], job["state"]), ("scan", "finished"))
|
||||
self.assertEqual(job["counters"]["added"], 1)
|
||||
self.assertEqual(job["progress"], 100)
|
||||
self.assertEqual(job["scope"]["output_folders"], 1)
|
||||
|
||||
async def test_scan_records_outcomes_by_group(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
(self.output_dir / "sub").mkdir()
|
||||
names = {
|
||||
"sub/added.png": {"status": "added", "title": "A red fox"},
|
||||
"linked.png": {"status": "linked", "title": "A grey cat"},
|
||||
"unchanged.png": {"status": "unchanged", "title": "A brown dog"},
|
||||
"ambiguous.png": {"status": "ambiguous"},
|
||||
"failed.png": RuntimeError(f"{self.tmpdir}/secret boom"),
|
||||
"none.png": None,
|
||||
}
|
||||
for name in names:
|
||||
(self.output_dir / name).write_bytes(b"not an image")
|
||||
|
||||
def fake_ingest(media_file, metadata, detailed=False):
|
||||
key = Path(media_file).relative_to(self.output_dir).as_posix()
|
||||
outcome = names[key]
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
self.api._ingest_scanned_file = fake_ingest
|
||||
with self.assertLogs("prompt_manager.api", level="ERROR"):
|
||||
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
self.assertEqual(status, 202)
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
|
||||
resp = await self.client.request(
|
||||
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
|
||||
)
|
||||
text = await resp.text()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertNotIn(self.tmpdir, text)
|
||||
job = json.loads(text)["job"]
|
||||
self.assertEqual(
|
||||
job["counters"],
|
||||
{"added": 2, "known": 1, "review": 1, "no_metadata": 1, "errors": 1},
|
||||
)
|
||||
outcomes = job["outcomes"]
|
||||
self.assertEqual(
|
||||
{k: len(v) for k, v in outcomes.items()},
|
||||
{"added": 2, "known": 1, "review": 1, "no_metadata": 1, "errors": 1},
|
||||
)
|
||||
added = {row["path"]: row for row in outcomes["added"]}
|
||||
self.assertEqual(set(added), {"sub/added.png", "linked.png"})
|
||||
self.assertIn("A red fox", added["sub/added.png"]["detail"])
|
||||
self.assertIn("A grey cat", added["linked.png"]["detail"])
|
||||
self.assertEqual(added["linked.png"]["root"], "output")
|
||||
self.assertIn("A brown dog", outcomes["known"][0]["detail"])
|
||||
self.assertIn("review", outcomes["review"][0]["detail"].lower())
|
||||
self.assertEqual(outcomes["errors"][0]["path"], "failed.png")
|
||||
self.assertTrue(outcomes["errors"][0]["detail"])
|
||||
self.assertEqual(outcomes["no_metadata"][0]["path"], "none.png")
|
||||
self.assertTrue(outcomes["no_metadata"][0]["detail"])
|
||||
prefixes = {line.split(" ", 2)[1] for line in job["log"][1:-1]}
|
||||
self.assertEqual(prefixes, {"+", "=", "?", "!", "-"})
|
||||
self.assertEqual(job["state"], "finished")
|
||||
self.assertTrue(job["status_line"].startswith("Processing file 6 of 6"))
|
||||
|
||||
legacy = self.api._scan_job.last_event
|
||||
self.assertEqual(
|
||||
(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)]
|
||||
for method, url in (
|
||||
("GET", "/prompt_manager/jobs/nope"),
|
||||
("GET", "/prompt_manager/jobs/nope/events"),
|
||||
("POST", "/prompt_manager/jobs/nope/cancel"),
|
||||
):
|
||||
status, data = await self._json(method, url)
|
||||
self.assertEqual(status, 404, url)
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
gate, calls = self._gate_collect()
|
||||
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
self.assertEqual(status, 202)
|
||||
job_id = started["job"]["id"]
|
||||
self.assertEqual(started["job"]["state"], "running")
|
||||
|
||||
status, busy = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
self.assertEqual(status, 409)
|
||||
self.assertEqual(
|
||||
busy["running"], {"id": job_id, "title": started["job"]["title"]}
|
||||
)
|
||||
self.assertEqual((busy["success"], busy["code"]), (False, "busy"))
|
||||
|
||||
watcher = asyncio.ensure_future(
|
||||
self.client.request("GET", f"/prompt_manager/jobs/{job_id}/events")
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
status, cancel = await self._json(
|
||||
"POST", f"/prompt_manager/jobs/{job_id}/cancel"
|
||||
)
|
||||
self.assertEqual((status, cancel["cancellation_requested"]), (200, True))
|
||||
gate.set()
|
||||
events = _sse_payloads(await (await watcher).text())
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(events[0]["id"], job_id)
|
||||
self.assertEqual(events[-1]["state"], "cancelled")
|
||||
status, cancel = await self._json(
|
||||
"POST", f"/prompt_manager/jobs/{job_id}/cancel"
|
||||
)
|
||||
self.assertEqual((status, cancel["cancellation_requested"]), (200, False))
|
||||
status, legacy = await self._json("GET", "/prompt_manager/scan/status")
|
||||
self.assertEqual(legacy["last_event"]["type"], "cancelled")
|
||||
|
||||
async def test_scan_without_directories_fails_the_job(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.outside_dir)]
|
||||
sys.modules.pop("folder_paths", None)
|
||||
self.api._find_comfyui_output_dir = lambda: None
|
||||
|
||||
status, started = await self._json("POST", "/prompt_manager/jobs/scan")
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
status, data = await self._json(
|
||||
"GET", f"/prompt_manager/jobs/{started['job']['id']}"
|
||||
)
|
||||
self.assertEqual(data["job"]["state"], "failed")
|
||||
self.assertIn("No output directories", data["job"]["status_line"])
|
||||
|
||||
async def test_legacy_scan_refused_while_another_record_job_runs(self):
|
||||
hold = asyncio.Event()
|
||||
|
||||
async def runner(job):
|
||||
await hold.wait()
|
||||
|
||||
other = self.api._jobs.start(
|
||||
"maintenance", "Maintenance", record_changing=True, scope={}, runner=runner
|
||||
)
|
||||
resp = await self.client.request("POST", "/prompt_manager/scan", data="{}")
|
||||
self.assertEqual(resp.status, 409)
|
||||
self.assertIn("Maintenance", (await resp.json())["error"])
|
||||
hold.set()
|
||||
await asyncio.wait_for(other.task, 5)
|
||||
|
||||
|
||||
class TestScanStatusLine(unittest.TestCase):
|
||||
|
||||
def test_estimate_appears_after_two_batches(self):
|
||||
from py.api.admin import _scan_status_line
|
||||
|
||||
self.assertEqual(
|
||||
_scan_status_line(50, 200, 1, 10.0), "Processing file 50 of 200"
|
||||
)
|
||||
self.assertEqual(
|
||||
_scan_status_line(100, 1000, 2, 30.0),
|
||||
"Processing file 100 of 1,000 · about 5 min left",
|
||||
)
|
||||
self.assertEqual(
|
||||
_scan_status_line(100, 120, 2, 1.0),
|
||||
"Processing file 100 of 120 · about 1 min left",
|
||||
)
|
||||
self.assertEqual(
|
||||
_scan_status_line(120, 120, 3, 9.0), "Processing file 120 of 120"
|
||||
)
|
||||
|
||||
|
||||
class TestWorkerThreadsSetting(AdminAPITestCase):
|
||||
"""The worker-thread count is exposed, validated, persisted and used by the scan."""
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Unit tests for py/api/jobs.py: the in-memory session job registry.
|
||||
|
||||
Fake runners only: no files, no database. Ordering is driven by events and
|
||||
awaited tasks, never by wall-clock bounds.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py.api.jobs import ( # noqa: E402
|
||||
LOG_CAP_LINE,
|
||||
MAX_FINISHED_JOBS,
|
||||
MAX_LOG_LINES,
|
||||
MAX_OUTCOME_ROWS,
|
||||
SCAN_GROUP_NAMES,
|
||||
SCAN_GROUPS,
|
||||
Job,
|
||||
JobBusy,
|
||||
JobRegistry,
|
||||
)
|
||||
|
||||
|
||||
async def _collect(job, stop_after=None):
|
||||
seen = []
|
||||
async with contextlib.aclosing(job.events()) as events:
|
||||
async for payload in events:
|
||||
seen.append(payload)
|
||||
if stop_after is not None and len(seen) >= stop_after:
|
||||
break
|
||||
return seen
|
||||
|
||||
|
||||
def _gated_runner(gate, publish=None):
|
||||
"""A runner that publishes ``publish`` (if any) and waits for ``gate``."""
|
||||
|
||||
async def runner(job):
|
||||
if publish is not None:
|
||||
job.publish(publish)
|
||||
await gate.wait()
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
async def _noop(job):
|
||||
return None
|
||||
|
||||
|
||||
class TestJobFanOut(unittest.IsolatedAsyncioTestCase):
|
||||
"""The SSE fan-out the scan job always had, now on every Job."""
|
||||
|
||||
async def test_new_job_is_running_with_no_event(self):
|
||||
job = Job("scan", "Scan")
|
||||
self.assertTrue(job.running)
|
||||
self.assertIsNone(job.last_event)
|
||||
self.assertEqual(job.state, "running")
|
||||
self.assertEqual(len(job.id), 32)
|
||||
|
||||
async def test_subscriber_receives_events_until_finish(self):
|
||||
job = Job("scan", "Scan")
|
||||
consumer = asyncio.ensure_future(_collect(job))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
job.publish({"type": "progress", "progress": 10})
|
||||
job.publish({"type": "complete"})
|
||||
job.finish("finished")
|
||||
|
||||
self.assertEqual(
|
||||
await asyncio.wait_for(consumer, 2),
|
||||
[{"type": "progress", "progress": 10}, {"type": "complete"}],
|
||||
)
|
||||
self.assertFalse(job.running)
|
||||
|
||||
async def test_late_subscriber_gets_latest_snapshot_then_live_events(self):
|
||||
job = Job("scan", "Scan")
|
||||
job.publish({"type": "progress", "progress": 10})
|
||||
job.publish({"type": "progress", "progress": 40})
|
||||
|
||||
consumer = asyncio.ensure_future(_collect(job))
|
||||
await asyncio.sleep(0)
|
||||
job.publish({"type": "complete"})
|
||||
job.finish()
|
||||
|
||||
self.assertEqual(
|
||||
await asyncio.wait_for(consumer, 2),
|
||||
[{"type": "progress", "progress": 40}, {"type": "complete"}],
|
||||
)
|
||||
|
||||
async def test_subscriber_after_finish_gets_only_the_final_event(self):
|
||||
job = Job("scan", "Scan")
|
||||
job.publish({"type": "progress", "progress": 99})
|
||||
job.publish({"type": "complete"})
|
||||
job.finish()
|
||||
|
||||
self.assertEqual(await _collect(job), [{"type": "complete"}])
|
||||
|
||||
async def test_departed_subscriber_is_forgotten(self):
|
||||
job = Job("scan", "Scan")
|
||||
job.publish({"type": "progress", "progress": 1})
|
||||
await _collect(job, stop_after=1)
|
||||
|
||||
self.assertEqual(job.subscriber_count, 0)
|
||||
job.publish({"type": "complete"}) # must not raise or queue anywhere
|
||||
job.finish()
|
||||
|
||||
async def test_second_subscriber_attaches_to_running_job(self):
|
||||
registry = JobRegistry()
|
||||
gate = asyncio.Event()
|
||||
job = registry.start(
|
||||
"scan",
|
||||
"Scan",
|
||||
record_changing=True,
|
||||
scope={},
|
||||
runner=_gated_runner(gate, {"type": "progress", "progress": 30}),
|
||||
)
|
||||
await asyncio.sleep(0) # the runner publishes its first event
|
||||
first = asyncio.ensure_future(_collect(job))
|
||||
second = asyncio.ensure_future(_collect(job))
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(job.subscriber_count, 2)
|
||||
self.assertIs(registry.active("scan"), job)
|
||||
|
||||
job.publish({"type": "progress", "progress": 60})
|
||||
gate.set()
|
||||
await asyncio.wait_for(job.task, 2)
|
||||
|
||||
expected = [
|
||||
{"type": "progress", "progress": 30},
|
||||
{"type": "progress", "progress": 60},
|
||||
]
|
||||
self.assertEqual(await asyncio.wait_for(first, 2), expected)
|
||||
self.assertEqual(await asyncio.wait_for(second, 2), expected)
|
||||
self.assertEqual(job.summary()["progress"], 60)
|
||||
|
||||
async def test_finish_rejects_unknown_state_and_is_final(self):
|
||||
job = Job("scan", "Scan")
|
||||
with self.assertRaises(ValueError):
|
||||
job.finish("running")
|
||||
job.finish("cancelled")
|
||||
job.finish("finished") # a second finish keeps the first state
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertIsNotNone(job.finished_at)
|
||||
|
||||
|
||||
class TestJobRegistry(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_start_lists_and_finishes_a_job(self):
|
||||
registry = JobRegistry()
|
||||
gate = asyncio.Event()
|
||||
job = registry.start(
|
||||
"scan",
|
||||
"Scan for metadata",
|
||||
record_changing=True,
|
||||
scope={"mode": "all"},
|
||||
runner=_gated_runner(gate),
|
||||
)
|
||||
self.assertEqual(job.state, "running")
|
||||
self.assertIs(registry.get(job.id), job)
|
||||
self.assertIsNone(registry.get("missing"))
|
||||
summary = job.summary()
|
||||
self.assertEqual(
|
||||
set(summary),
|
||||
{
|
||||
"id",
|
||||
"kind",
|
||||
"title",
|
||||
"state",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"progress",
|
||||
"status_line",
|
||||
"processed",
|
||||
"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()
|
||||
await asyncio.wait_for(job.task, 2)
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertIsNone(registry.active("scan"))
|
||||
|
||||
# Running jobs list first, then finished ones newest first.
|
||||
older = registry.start(
|
||||
"thumbs", "Thumbnails", record_changing=False, scope={}, runner=_noop
|
||||
)
|
||||
await asyncio.wait_for(older.task, 2)
|
||||
hold = asyncio.Event()
|
||||
running = registry.start(
|
||||
"other",
|
||||
"Other",
|
||||
record_changing=False,
|
||||
scope={},
|
||||
runner=_gated_runner(hold),
|
||||
)
|
||||
listed = registry.list()
|
||||
self.assertEqual([j.id for j in listed], [running.id, older.id, job.id])
|
||||
|
||||
# Only the last MAX_FINISHED_JOBS finished jobs are kept.
|
||||
for index in range(MAX_FINISHED_JOBS + 5):
|
||||
done = registry.start(
|
||||
f"k{index}", "Fake", record_changing=False, scope={}, runner=_noop
|
||||
)
|
||||
await asyncio.wait_for(done.task, 2)
|
||||
listed = registry.list()
|
||||
finished = [j for j in listed if not j.running]
|
||||
self.assertEqual(len(finished), MAX_FINISHED_JOBS)
|
||||
self.assertEqual(finished[0].id, done.id)
|
||||
self.assertIsNone(registry.get(job.id))
|
||||
self.assertIs(listed[0], running)
|
||||
hold.set()
|
||||
await asyncio.wait_for(running.task, 2)
|
||||
|
||||
async def test_record_changing_jobs_are_exclusive(self):
|
||||
registry = JobRegistry()
|
||||
gate = asyncio.Event()
|
||||
first = registry.start(
|
||||
"scan",
|
||||
"Scan for metadata",
|
||||
record_changing=True,
|
||||
scope={},
|
||||
runner=_gated_runner(gate),
|
||||
)
|
||||
with self.assertRaises(JobBusy) as raised:
|
||||
registry.start(
|
||||
"maintenance",
|
||||
"Maintenance",
|
||||
record_changing=True,
|
||||
scope={},
|
||||
runner=_noop,
|
||||
)
|
||||
self.assertIs(raised.exception.running, first)
|
||||
self.assertIn("Scan for metadata", str(raised.exception))
|
||||
|
||||
reader = registry.start(
|
||||
"report", "Report", record_changing=False, scope={}, runner=_noop
|
||||
)
|
||||
await asyncio.wait_for(reader.task, 2)
|
||||
self.assertEqual(reader.state, "finished")
|
||||
|
||||
# One job per kind, even a read-only one.
|
||||
hold = asyncio.Event()
|
||||
registry.start(
|
||||
"report",
|
||||
"Report",
|
||||
record_changing=False,
|
||||
scope={},
|
||||
runner=_gated_runner(hold),
|
||||
)
|
||||
with self.assertRaises(JobBusy):
|
||||
registry.start(
|
||||
"report", "Report", record_changing=False, scope={}, runner=_noop
|
||||
)
|
||||
gate.set()
|
||||
hold.set()
|
||||
await asyncio.wait_for(first.task, 2)
|
||||
|
||||
async def test_cancel_is_idempotent_and_keeps_partial_counts(self):
|
||||
registry = JobRegistry()
|
||||
reached = asyncio.Event()
|
||||
proceed = asyncio.Event()
|
||||
|
||||
async def runner(job):
|
||||
for index in range(3):
|
||||
if job.cancelled:
|
||||
return
|
||||
job.record("added", {"path": f"a{index}.png", "root": "output"}, "+")
|
||||
if index == 0:
|
||||
reached.set()
|
||||
await proceed.wait()
|
||||
|
||||
job = registry.start(
|
||||
"scan",
|
||||
"Scan",
|
||||
record_changing=True,
|
||||
scope={},
|
||||
runner=runner,
|
||||
groups=SCAN_GROUP_NAMES,
|
||||
)
|
||||
await asyncio.wait_for(reached.wait(), 2)
|
||||
self.assertTrue(registry.cancel(job.id))
|
||||
self.assertTrue(registry.cancel(job.id)) # still running: still requested
|
||||
proceed.set()
|
||||
await asyncio.wait_for(job.task, 2)
|
||||
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.counters["added"], 1) # the work done before the stop
|
||||
self.assertFalse(registry.cancel(job.id)) # finished: a no-op
|
||||
self.assertFalse(registry.cancel("missing"))
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
|
||||
async def test_failing_runner_ends_failed_and_frees_the_slot(self):
|
||||
registry = JobRegistry()
|
||||
|
||||
async def runner(job):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
job = registry.start(
|
||||
"scan", "Scan", record_changing=True, scope={}, runner=runner
|
||||
)
|
||||
with self.assertLogs("py.api.jobs", level="ERROR"):
|
||||
await asyncio.wait_for(job.task, 2)
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertIsNone(registry.active("scan"))
|
||||
|
||||
async def test_runner_that_finishes_its_own_job_keeps_its_state(self):
|
||||
registry = JobRegistry()
|
||||
|
||||
async def runner(job):
|
||||
job.finish("failed")
|
||||
|
||||
job = registry.start(
|
||||
"scan", "Scan", record_changing=True, scope={}, runner=runner
|
||||
)
|
||||
await asyncio.wait_for(job.task, 2)
|
||||
self.assertEqual(job.state, "failed")
|
||||
|
||||
|
||||
class TestJobOutcomesAndLog(unittest.TestCase):
|
||||
|
||||
def test_outcome_and_log_caps(self):
|
||||
job = Job("scan", "Scan", groups=SCAN_GROUP_NAMES)
|
||||
for index in range(MAX_OUTCOME_ROWS + 50):
|
||||
job.record("errors", {"path": f"f{index}.png", "root": "output"}, "!")
|
||||
self.assertEqual(len(job.outcomes["errors"]), MAX_OUTCOME_ROWS)
|
||||
self.assertEqual(job.counters["errors"], MAX_OUTCOME_ROWS + 50)
|
||||
self.assertEqual(job.outcomes["errors"][0]["path"], "f0.png")
|
||||
|
||||
job = Job("scan", "Scan")
|
||||
for index in range(MAX_LOG_LINES + 100):
|
||||
job.log_line(f"line {index}")
|
||||
self.assertEqual(len(job.log), MAX_LOG_LINES + 1)
|
||||
self.assertEqual(job.log[-1], LOG_CAP_LINE)
|
||||
self.assertTrue(job.log[0].endswith("line 0"))
|
||||
self.assertEqual(LOG_CAP_LINE, "[Log capped at 2,000 lines]")
|
||||
|
||||
def test_paths_are_root_relative(self):
|
||||
job = Job("scan", "Scan", groups=SCAN_GROUP_NAMES)
|
||||
absolute = os.path.abspath(os.path.join("outputs", "sub", "a.png"))
|
||||
job.record(
|
||||
"added",
|
||||
{"path": absolute, "root": "output", "detail": "x"},
|
||||
f"+ {absolute}",
|
||||
)
|
||||
job.record("known", {"path": "sub/b.png", "root": "output"}, "= sub/b.png")
|
||||
job.record("review", {"path": "C:\\out\\c.png", "root": "output"}, "?")
|
||||
rows = [row for group in job.outcomes.values() for row in group]
|
||||
for row in rows:
|
||||
self.assertFalse(os.path.isabs(row["path"]), row)
|
||||
self.assertNotIn(":", row["path"])
|
||||
self.assertEqual(job.outcomes["added"][0]["path"], "a.png")
|
||||
self.assertEqual(job.outcomes["known"][0]["path"], "sub/b.png")
|
||||
self.assertEqual(job.outcomes["review"][0]["path"], "c.png")
|
||||
self.assertEqual(job.outcomes["known"][0]["detail"], "")
|
||||
self.assertNotIn(absolute, "\n".join(job.log))
|
||||
|
||||
def test_record_rejects_unknown_group(self):
|
||||
job = Job("scan", "Scan", groups=SCAN_GROUP_NAMES)
|
||||
with self.assertRaises(ValueError):
|
||||
job.record("bogus", {"path": "a.png", "root": "output"}, "x")
|
||||
|
||||
def test_detail_and_progress(self):
|
||||
job = Job("scan", "Scan", scope={"mode": "all"}, groups=SCAN_GROUP_NAMES)
|
||||
job.update(progress=150, status_line="Processing file 3 of 4", processed=3)
|
||||
job.update(total=4)
|
||||
job.record("known", {"path": "a.png", "root": "output"}, "= a.png")
|
||||
detail = job.detail()
|
||||
self.assertEqual(detail["progress"], 100)
|
||||
self.assertEqual(detail["status_line"], "Processing file 3 of 4")
|
||||
self.assertEqual((detail["processed"], detail["total"]), (3, 4))
|
||||
self.assertEqual(detail["counters"]["known"], 1)
|
||||
self.assertEqual(detail["outcomes"]["known"][0]["path"], "a.png")
|
||||
self.assertEqual(len(detail["log"]), 1)
|
||||
detail["outcomes"]["known"].clear() # a copy, not the live list
|
||||
self.assertEqual(len(job.outcomes["known"]), 1)
|
||||
job.publish({"type": "progress", "progress": -5})
|
||||
self.assertEqual(job.summary()["progress"], 0)
|
||||
|
||||
def test_scan_groups_follow_the_counter_mapping(self):
|
||||
self.assertEqual(
|
||||
SCAN_GROUPS,
|
||||
{
|
||||
"added": "added",
|
||||
"linked": "added",
|
||||
"enriched": "added",
|
||||
"unchanged": "known",
|
||||
"ambiguous": "review",
|
||||
"conflict": "review",
|
||||
"unresolved": "review",
|
||||
"failed": "errors",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
SCAN_GROUP_NAMES, ("added", "known", "review", "no_metadata", "errors")
|
||||
)
|
||||
|
||||
|
||||
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"])
|
||||
@@ -120,9 +120,9 @@ class TestPairScan(AdminAPITestCase):
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "unchanged")
|
||||
|
||||
async def test_cancel_finishes_at_safe_boundary_and_restart_is_idempotent(self):
|
||||
from py.api.scan_job import ScanJob
|
||||
from py.api.jobs import Job
|
||||
|
||||
job = ScanJob()
|
||||
job = Job("scan", "Scan")
|
||||
self.api._scan_job = job
|
||||
response = await self.client.post("/prompt_manager/scan/cancel")
|
||||
self.assertEqual(response.status, 200)
|
||||
@@ -133,7 +133,7 @@ class TestPairScan(AdminAPITestCase):
|
||||
self.assertEqual(counts["processed"], 0)
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
job.finish()
|
||||
self.api._scan_job = ScanJob()
|
||||
self.api._scan_job = Job("scan", "Scan")
|
||||
first = self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "cancel.png", {"prompt": json.dumps(graph())})]
|
||||
)
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Unit tests for py/api/scan_job.py: fan-out of scan progress to SSE subscribers."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py.api.scan_job import ScanJob # noqa: E402
|
||||
|
||||
|
||||
async def _collect(job, stop_after=None):
|
||||
seen = []
|
||||
async with contextlib.aclosing(job.events()) as events:
|
||||
async for payload in events:
|
||||
seen.append(payload)
|
||||
if stop_after is not None and len(seen) >= stop_after:
|
||||
break
|
||||
return seen
|
||||
|
||||
|
||||
class TestScanJob(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_new_job_is_running_with_no_event(self):
|
||||
job = ScanJob()
|
||||
self.assertTrue(job.running)
|
||||
self.assertIsNone(job.last_event)
|
||||
|
||||
async def test_subscriber_receives_events_until_finish(self):
|
||||
job = ScanJob()
|
||||
consumer = asyncio.ensure_future(_collect(job))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
job.publish({"type": "progress", "progress": 10})
|
||||
job.publish({"type": "complete"})
|
||||
job.finish()
|
||||
|
||||
self.assertEqual(
|
||||
await asyncio.wait_for(consumer, 2),
|
||||
[{"type": "progress", "progress": 10}, {"type": "complete"}],
|
||||
)
|
||||
self.assertFalse(job.running)
|
||||
|
||||
async def test_late_subscriber_gets_latest_snapshot_then_live_events(self):
|
||||
job = ScanJob()
|
||||
job.publish({"type": "progress", "progress": 10})
|
||||
job.publish({"type": "progress", "progress": 40})
|
||||
|
||||
consumer = asyncio.ensure_future(_collect(job))
|
||||
await asyncio.sleep(0)
|
||||
job.publish({"type": "complete"})
|
||||
job.finish()
|
||||
|
||||
self.assertEqual(
|
||||
await asyncio.wait_for(consumer, 2),
|
||||
[{"type": "progress", "progress": 40}, {"type": "complete"}],
|
||||
)
|
||||
|
||||
async def test_subscriber_after_finish_gets_only_the_final_event(self):
|
||||
job = ScanJob()
|
||||
job.publish({"type": "progress", "progress": 99})
|
||||
job.publish({"type": "complete"})
|
||||
job.finish()
|
||||
|
||||
self.assertEqual(await _collect(job), [{"type": "complete"}])
|
||||
|
||||
async def test_two_subscribers_both_see_every_event(self):
|
||||
job = ScanJob()
|
||||
first = asyncio.ensure_future(_collect(job))
|
||||
second = asyncio.ensure_future(_collect(job))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
job.publish({"type": "progress", "progress": 1})
|
||||
job.finish()
|
||||
|
||||
self.assertEqual(await first, [{"type": "progress", "progress": 1}])
|
||||
self.assertEqual(await second, [{"type": "progress", "progress": 1}])
|
||||
|
||||
async def test_departed_subscriber_is_forgotten(self):
|
||||
job = ScanJob()
|
||||
job.publish({"type": "progress", "progress": 1})
|
||||
await _collect(job, stop_after=1)
|
||||
|
||||
self.assertEqual(job.subscriber_count, 0)
|
||||
job.publish({"type": "complete"}) # must not raise or queue anywhere
|
||||
job.finish()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+38
-14
@@ -155,12 +155,36 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/file-inspector.mjs": {
|
||||
"workspace/activity-job-view.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 83,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/activity-scan.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 92,
|
||||
"functions": 88,
|
||||
"branches": 64,
|
||||
"functions": 98,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/activity-store.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/activity.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 87,
|
||||
"functions": 98,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 92,
|
||||
"functions": 92,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/heading.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -229,7 +253,7 @@
|
||||
},
|
||||
"workspace/index.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 96,
|
||||
"branches": 97,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -264,10 +288,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library.mjs": {
|
||||
"lines": 95,
|
||||
"lines": 96,
|
||||
"branches": 85,
|
||||
"functions": 96,
|
||||
"statements": 95
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/media-viewer.mjs": {
|
||||
"lines": 98,
|
||||
@@ -282,10 +306,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-blocks.mjs": {
|
||||
"lines": 89,
|
||||
"branches": 89,
|
||||
"lines": 90,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 89
|
||||
"statements": 90
|
||||
},
|
||||
"workspace/prompt-inspector.mjs": {
|
||||
"lines": 98,
|
||||
@@ -302,7 +326,7 @@
|
||||
"workspace/recorded-run.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 83,
|
||||
"functions": 77,
|
||||
"functions": 80,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/saved-views.mjs": {
|
||||
@@ -325,15 +349,15 @@
|
||||
},
|
||||
"workspace/toast.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 64,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/toolbar.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 93,
|
||||
"lines": 96,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 94
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/topbar.mjs": {
|
||||
"lines": 98,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* State of the output-image scan as seen by the dashboard.
|
||||
*
|
||||
* The scan runs on the server as a background job (see py/api/scan_job.py) and
|
||||
* The scan runs on the server as a background job (see py/api/jobs.py) and
|
||||
* reports progress over SSE. This module turns those events into a small
|
||||
* immutable state object and answers the UI questions that depend on it:
|
||||
* what dismissing the modal should do, what the minimized pill should say, and
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* One job's Activity view (#249 Activity-1, D56): `#/activity/<id>`, the approved mockup `activity-scan.html` in the
|
||||
* concept's classes.
|
||||
*
|
||||
* - Header: the page heading names the job; its line is the Activity item's (D54), and its actions are Download report
|
||||
* (disabled until Activity-2) and, while the job runs, "Cancel scan" (danger).
|
||||
* - Progress (`section.surface`): `<progress>`, the server's "Processing file N of M · about X min left", and the
|
||||
* outcome counters as `dl.stat-row` tiles: Added to Library (green), Already known, Needs review (warning), No usable
|
||||
* metadata, Errors (red).
|
||||
* - Results (`section.surface`): the outcome list (`button[aria-pressed]` with counts, in the mockup's order), a path
|
||||
* filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and
|
||||
* Already known open the file in the existing viewer through its listing identity (D57); the others are plain text.
|
||||
* - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { parseJob, JOBS_URL, LOST_DETAIL } from './activity-store.mjs';
|
||||
import { jobDetail, CANCEL_FAILED_TOAST } from './activity.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { imageKey } from './images-state.mjs';
|
||||
|
||||
export const DETAIL_INTERVAL_MS = 1000;
|
||||
export const NOT_FOUND = 'This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.';
|
||||
export const EMPTY_GROUP = 'No files in this group.';
|
||||
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 = 5;
|
||||
|
||||
/** The counter tiles, in the spec's order and the mockup's tones. */
|
||||
export const TILES = Object.freeze([
|
||||
['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'],
|
||||
['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'],
|
||||
]);
|
||||
/** The outcome list, in the mockup's order: what got added and what went wrong first. */
|
||||
export const OUTCOMES = Object.freeze([
|
||||
['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'],
|
||||
['no_metadata', 'No usable metadata'], ['known', 'Already known'],
|
||||
]);
|
||||
const OPENABLE = new Set(['added', 'known']);
|
||||
|
||||
function element(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
// 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. */
|
||||
export function parseDetail(raw) {
|
||||
const job = parseJob(raw);
|
||||
if (!job) return null;
|
||||
const outcomes = {};
|
||||
const groups = raw.outcomes !== null && typeof raw.outcomes === 'object' ? raw.outcomes : {};
|
||||
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 : '',
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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}%`;
|
||||
if (job.state === 'finished') return `${files} processed`;
|
||||
if (job.state === 'cancelled') return `Cancelled after ${files}`;
|
||||
if (job.state === 'lost') return LOST_DETAIL;
|
||||
return job.status_line || 'The job stopped on an error.';
|
||||
}
|
||||
|
||||
/** The note under the rows: "Showing N of M" when not every file is listed, and the viewer hint for openable rows. */
|
||||
export function resultsNote(shown, total, openable) {
|
||||
const parts = [];
|
||||
if (shown < total) parts.push(`Showing ${number(shown)} of ${number(total)}`);
|
||||
if (openable && shown > 0) parts.push(VIEWER_HINT);
|
||||
if (!parts.length) return '';
|
||||
const text = parts.join(' · ');
|
||||
return parts.length === 1 && openable && shown >= total ? `${text[0].toUpperCase()}${text.slice(1)}.` : text;
|
||||
}
|
||||
|
||||
/** Rows whose path (or root label and path) holds the case-folded filter. */
|
||||
export function filterRows(rows, filter) {
|
||||
const wanted = filter.trim().toLocaleLowerCase();
|
||||
if (!wanted) return rows;
|
||||
return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted));
|
||||
}
|
||||
|
||||
/** The first outcome with files, so a view opens on what happened; Added when nothing has yet. */
|
||||
export function defaultOutcome(counters) {
|
||||
return OUTCOMES.find(([group]) => (counters[group] ?? 0) > 0)?.[0] ?? 'added';
|
||||
}
|
||||
|
||||
function section(title, id) {
|
||||
const node = element('section', undefined, 'surface');
|
||||
const heading = element('div', undefined, 'panel-heading');
|
||||
const h2 = element('h2', title);
|
||||
h2.id = id;
|
||||
node.setAttribute('aria-labelledby', id);
|
||||
heading.append(h2);
|
||||
node.append(heading);
|
||||
return { node, heading };
|
||||
}
|
||||
|
||||
/** `#/activity/<id>`: one job's view. */
|
||||
export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
const parts = pageHeading(main);
|
||||
const download = createButton('Download report');
|
||||
download.disabled = true;
|
||||
const cancel = createButton('Cancel scan', 'danger');
|
||||
|
||||
const progressPart = section('Progress', 'job-progress-title');
|
||||
const status = element('span', '', 'small');
|
||||
progressPart.heading.append(status);
|
||||
const bar = element('progress');
|
||||
bar.max = 100;
|
||||
const stats = element('dl', undefined, 'stat-row');
|
||||
const tileValues = new Map();
|
||||
for (const [group, label, tone] of TILES) {
|
||||
const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat');
|
||||
const value = element('dd', '0');
|
||||
tile.append(element('dt', label), value);
|
||||
tileValues.set(group, value);
|
||||
stats.append(tile);
|
||||
}
|
||||
progressPart.node.append(bar, stats);
|
||||
|
||||
const resultsPart = section('Results', 'job-results-title');
|
||||
const search = element('label', undefined, 'search-field');
|
||||
const input = element('input');
|
||||
input.type = 'search';
|
||||
input.placeholder = 'Filter by file path…';
|
||||
input.setAttribute('aria-label', 'Filter results');
|
||||
search.append(input);
|
||||
resultsPart.heading.append(search);
|
||||
const outcomes = element('div', undefined, 'outcomes');
|
||||
const list = element('div', undefined, 'outcome-list');
|
||||
list.setAttribute('role', 'group');
|
||||
list.setAttribute('aria-label', 'Outcomes');
|
||||
const files = element('div', undefined, 'outcome-files');
|
||||
const buttons = new Map();
|
||||
for (const [group, label] of OUTCOMES) {
|
||||
const button = createButton('');
|
||||
const count = element('span', '0');
|
||||
button.append(element('span', label), count);
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.count = count;
|
||||
buttons.set(group, button);
|
||||
list.append(button);
|
||||
}
|
||||
outcomes.append(list, files);
|
||||
resultsPart.node.append(outcomes);
|
||||
|
||||
const log = element('details', undefined, 'term');
|
||||
const summary = element('summary');
|
||||
const logLabel = document.createTextNode('');
|
||||
summary.append(logLabel, element('span', ' — raw detail, click to show'));
|
||||
const pre = element('pre', '');
|
||||
pre.tabIndex = 0;
|
||||
log.append(summary, pre);
|
||||
|
||||
const missing = element('section', undefined, 'surface');
|
||||
const back = element('a', 'All activity');
|
||||
back.href = '#/activity';
|
||||
missing.append(element('p', NOT_FOUND, 'empty-state'), back);
|
||||
|
||||
let detail = null;
|
||||
let selected = null;
|
||||
let mounted = false;
|
||||
let lastRead = 0;
|
||||
let reading = false;
|
||||
let readAgain = false;
|
||||
let readTimer = null;
|
||||
let roots = null;
|
||||
let lastState = 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());
|
||||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection,
|
||||
sender: createPromptSender({ scope }), onSelect: image => inspection.select(image) });
|
||||
|
||||
function showBody(job) {
|
||||
if (mounted) return;
|
||||
mounted = true;
|
||||
missing.remove();
|
||||
parts.actions.append(download, cancel);
|
||||
main.append(progressPart.node, resultsPart.node, log);
|
||||
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) {
|
||||
const key = `${selected}\n${index}\n${row.root}\n${row.path}`;
|
||||
let node = rowNodes.get(key);
|
||||
if (!node) {
|
||||
node = openable ? createButton('', 'file-row') : element('div', undefined, 'file-row');
|
||||
node.append(element('span', row.path, 'path'), element('span', row.detail, 'why'));
|
||||
if (openable) node.row = row;
|
||||
}
|
||||
return [key, node];
|
||||
}
|
||||
|
||||
function renderRows(job) {
|
||||
const rows = detail?.outcomes[selected] ?? [];
|
||||
const openable = OPENABLE.has(selected);
|
||||
const wanted = filterRows(rows.map((row, index) => ({ ...row, index })), input.value);
|
||||
const kept = new Map(wanted.map(row => rowNode(row, row.index, openable)));
|
||||
rowNodes = kept;
|
||||
const nodes = [...kept.values()];
|
||||
// The counter is exact; at most 200 of its files are listed, and the filter narrows those.
|
||||
const total = Math.max(job.counters[selected] ?? 0, rows.length);
|
||||
if (!nodes.length) nodes.push(element('p', rows.length ? NO_MATCH : EMPTY_GROUP, 'empty-state'));
|
||||
const note = wanted.length ? resultsNote(wanted.length, total, openable) : '';
|
||||
if (note) nodes.push(element('p', note, 'small'));
|
||||
for (const child of [...files.children]) if (!nodes.includes(child)) child.remove();
|
||||
nodes.forEach((node, index) => {
|
||||
// Only a node out of place moves, so focus survives a re-read.
|
||||
if (files.children[index] !== node) files.insertBefore(node, files.children[index] ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const job = current();
|
||||
if (!job) {
|
||||
if (listedOnce || notFound) showMissing();
|
||||
return;
|
||||
}
|
||||
showBody(job);
|
||||
const scan = job.kind === 'scan';
|
||||
parts.h1.textContent = job.title;
|
||||
document.title = `${job.title} · Prompt Manager`;
|
||||
parts.description.textContent = jobDetail(job);
|
||||
cancel.textContent = scan ? 'Cancel scan' : 'Cancel job';
|
||||
if (job.state === 'running') {
|
||||
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.
|
||||
if (document.activeElement === cancel) parts.h1.focus();
|
||||
cancel.remove();
|
||||
}
|
||||
status.textContent = progressLine(job);
|
||||
bar.value = job.progress;
|
||||
bar.textContent = `${job.progress}%`;
|
||||
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) {
|
||||
button.count.textContent = number(job.counters[group] ?? 0);
|
||||
button.setAttribute('aria-pressed', String(group === selected));
|
||||
}
|
||||
logLabel.textContent = `● ${scan ? 'Scan' : 'Job'} log`;
|
||||
const text = detail?.log.length ? detail.log.join('\n') : 'No log lines yet.';
|
||||
if (pre.textContent !== text) {
|
||||
const atEnd = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 2;
|
||||
pre.textContent = text;
|
||||
if (atEnd) pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
renderRows(job);
|
||||
}
|
||||
|
||||
/** Read the rows and the log, at most once a second; a request while one is in flight reads once more after it. */
|
||||
function scheduleRead() {
|
||||
if (scope.signal.aborted) return;
|
||||
if (reading) { readAgain = true; return; }
|
||||
if (readTimer !== null) return;
|
||||
const wait = Math.max(0, lastRead + DETAIL_INTERVAL_MS - Date.now());
|
||||
readTimer = setTimeout(() => { readTimer = null; read(); }, wait);
|
||||
}
|
||||
scope.cleanup(() => { if (readTimer !== null) clearTimeout(readTimer); });
|
||||
|
||||
async function read() {
|
||||
reading = true;
|
||||
lastRead = Date.now();
|
||||
try {
|
||||
const result = await api.get(`${JOBS_URL}/${jobId}`, { signal: scope.signal, cache: 'no-store' });
|
||||
if (scope.signal.aborted) return;
|
||||
const parsed = parseDetail(result?.job);
|
||||
if (parsed && parsed.job.id === jobId) detail = parsed;
|
||||
} 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) notFound = true;
|
||||
} finally {
|
||||
reading = false;
|
||||
}
|
||||
render();
|
||||
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;
|
||||
// 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');
|
||||
try {
|
||||
await store.cancel(jobId);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(CANCEL_FAILED_TOAST);
|
||||
} finally {
|
||||
cancel.removeAttribute('aria-disabled');
|
||||
}
|
||||
});
|
||||
scope.listen(list, 'click', event => {
|
||||
const button = event.target.closest('button');
|
||||
if (!button) return;
|
||||
selected = [...buttons].find(([, node]) => node === button)?.[0] ?? selected;
|
||||
render();
|
||||
});
|
||||
scope.listen(input, 'input', () => { const job = current(); if (job) renderRows(job); });
|
||||
scope.listen(files, 'click', event => {
|
||||
const button = event.target.closest('button.file-row');
|
||||
if (button?.row) openRow(button.row, button);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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?.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;
|
||||
}
|
||||
|
||||
async function openRow(row, trigger) {
|
||||
if (trigger.getAttribute('aria-disabled') === 'true') return;
|
||||
trigger.setAttribute('aria-disabled', 'true');
|
||||
try {
|
||||
const item = await listed(row);
|
||||
if (scope.signal.aborted) return;
|
||||
if (!item) { showWorkspaceToast(FILE_GONE); return; }
|
||||
viewer.open({ items: [item], selectedKey: imageKey(item), trigger });
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(OPEN_FAILED);
|
||||
} finally {
|
||||
trigger.removeAttribute('aria-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
lastState = store.find(jobId)?.state ?? null;
|
||||
scope.cleanup(store.subscribe(follow));
|
||||
render();
|
||||
read();
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Scan for metadata (#249 Activity-1, D55): Images → Import → "Scan for metadata" → Review scan opens the concept's
|
||||
* "Review metadata scan". Its backup box downloads the existing `/prompt_manager/backup` copy and toasts the outcome;
|
||||
* Start scan starts the session job through the Activity store, which toasts "Scan started.", and opens the job's view.
|
||||
* A start refused because another record-changing job runs toasts "Another job is running: <title>." with a link to
|
||||
* that job (D57).
|
||||
*/
|
||||
import { openScanReviewDialog } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
|
||||
export const BACKUP_URL = '/prompt_manager/backup';
|
||||
export const BACKUP_DONE = 'Database backup downloaded.';
|
||||
export const BACKUP_FAILED = 'The database could not be backed up. Try again.';
|
||||
export const START_FAILED = 'The scan could not start. Try again.';
|
||||
const FALLBACK_NAME = 'prompts_backup.db';
|
||||
|
||||
/** The download name from a Content-Disposition header, or the fallback. */
|
||||
export function backupFilename(disposition) {
|
||||
const match = /filename="?([^";]+)"?/i.exec(disposition || '');
|
||||
return match ? match[1] : FALLBACK_NAME;
|
||||
}
|
||||
|
||||
/** Download the prompts database copy the legacy admin page also uses; toasts the outcome. */
|
||||
export async function downloadDatabaseBackup({ fetchUrl = window.ApiClient.fetchUrl, notify = showWorkspaceToast } = {}) {
|
||||
try {
|
||||
const response = await fetchUrl(BACKUP_URL);
|
||||
if (!response.ok) throw new Error(`Backup failed with ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = backupFilename(response.headers.get('Content-Disposition'));
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
notify(BACKUP_DONE);
|
||||
return true;
|
||||
} catch {
|
||||
notify(BACKUP_FAILED);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The toast for a refused start, linking the job that holds the slot. */
|
||||
export function busyToast(running) {
|
||||
if (!running) return 'Another job is running. Try again when it ends.';
|
||||
return ['Another job is running: ', { text: running.title, href: `#/activity/${running.id}` }, '.'];
|
||||
}
|
||||
|
||||
/** Start the scan job and open its view; a refused or failed start says why and stays on the page. */
|
||||
export async function startMetadataScan({ store, scope, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
try {
|
||||
const result = await store.start('scan');
|
||||
if (scope.signal.aborted) return;
|
||||
if (result.busy !== undefined) {
|
||||
notify(busyToast(result.busy));
|
||||
return;
|
||||
}
|
||||
navigate(`#/activity/${result.job.id}`);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) notify(START_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open "Review metadata scan" for `sources` output folders. */
|
||||
export function openMetadataScan({ scope, store, sources, trigger = null }) {
|
||||
openScanReviewDialog({ scope, sources, trigger,
|
||||
onBackup: () => downloadDatabaseBackup(),
|
||||
onStart: () => startMetadataScan({ store, scope }) });
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* The Activity store (#249 Activity-1): this server session's jobs, for the top-bar button and pill and the Activity
|
||||
* page. One `GET /prompt_manager/jobs` read, then one SSE stream (`/jobs/<id>/events`) per running job.
|
||||
*
|
||||
* - A dropped stream re-reads the list after a backoff that doubles from 1 s and is capped at 30 s, so a stopped
|
||||
* server never sees a retry storm. A successful stream message resets the backoff.
|
||||
* - While the tab is visible the list is read again every 15 s, so a job started elsewhere (another tab, the legacy
|
||||
* admin page) appears without a reload.
|
||||
* - Jobs live only in server memory. A running job the server no longer lists (ComfyUI restarted), or one whose
|
||||
* server cannot be read at all, is shown as ended (`lost`), so no pill stays stuck.
|
||||
* - Toasts announce what happened; Activity is where progress lives. "Scan started." follows this tab's own start;
|
||||
* the finish toast follows any job this store saw running when it ends.
|
||||
*/
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
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']);
|
||||
const JOB_ID = /^[0-9a-f]{32}$/;
|
||||
// The pill's verb for each job kind; a kind without one shows its title.
|
||||
const PILL_VERBS = Object.freeze({ scan: 'Scanning' });
|
||||
const START_TOASTS = Object.freeze({ scan: 'Scan started.' });
|
||||
|
||||
/** 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) {
|
||||
const step = Number.isSafeInteger(attempt) && attempt > 0 ? Math.min(attempt, 16) : 0;
|
||||
return Math.min(BACKOFF_MAX_MS, BACKOFF_FIRST_MS * 2 ** step);
|
||||
}
|
||||
|
||||
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
||||
const time = value => typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
const plainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** A job summary from the API, or null when it is not one (unknown state, bad id). */
|
||||
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,
|
||||
kind: typeof raw.kind === 'string' ? raw.kind : '',
|
||||
title: typeof raw.title === 'string' && raw.title.trim() ? raw.title : 'Background job',
|
||||
state: raw.state,
|
||||
started_at: time(raw.started_at),
|
||||
finished_at: time(raw.finished_at),
|
||||
progress: Math.max(0, Math.min(100, progress)),
|
||||
status_line: typeof raw.status_line === 'string' ? raw.status_line : '',
|
||||
processed: count(raw.processed),
|
||||
total: count(raw.total),
|
||||
counters,
|
||||
scope: plainObject(raw.scope) ? { ...raw.scope } : {},
|
||||
listed,
|
||||
log_length: count(raw.log_length),
|
||||
};
|
||||
}
|
||||
|
||||
// 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". */
|
||||
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.state === 'cancelled') return `${job.title} was cancelled.`;
|
||||
if (job.state === 'failed') return `${job.title} failed.`;
|
||||
return `${job.title} finished.`;
|
||||
}
|
||||
|
||||
/** The toast after this tab starts a job. */
|
||||
export function startToast(job) {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** The scan's counters as one line: "N added · M errors"; other kinds have none yet. */
|
||||
export function outcomeLine(job) {
|
||||
return job.kind === 'scan' ? `${addedOf(job)} · ${errorsOf(job)}` : '';
|
||||
}
|
||||
|
||||
function defaultConnect(url) {
|
||||
const resolve = globalThis.ApiClient?.resolveUrl ?? (value => value);
|
||||
return new EventSource(resolve(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{api: {get: Function, post: Function}, scope: {signal: AbortSignal, cleanup: Function},
|
||||
* connect?: (url: string) => {onmessage: Function|null, onerror: Function|null, close: Function},
|
||||
* notify?: (message: string) => void, timers?: {setTimeout: Function, clearTimeout: Function},
|
||||
* pollMs?: number, isVisible?: () => boolean, lifecycle?: EventTarget}} options
|
||||
*
|
||||
* `lifecycle` (the window) ends the store's work on `pagehide`: the periodic and backoff reads stop, the streams close
|
||||
* and a read in flight is aborted, so nothing answers into a page that is going away (a closed tab, a test teardown).
|
||||
* A `pageshow` from the back-forward cache reads the list again.
|
||||
*/
|
||||
export function createActivityStore({ api, scope, connect = defaultConnect, notify = showWorkspaceToast,
|
||||
timers = globalThis, pollMs = POLL_MS, isVisible = () => globalThis.document?.visibilityState !== 'hidden',
|
||||
lifecycle = globalThis.window ?? null } = {}) {
|
||||
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;
|
||||
let disposed = false;
|
||||
let suspended = false;
|
||||
let reading = null;
|
||||
const streams = new Map();
|
||||
const listeners = new Set();
|
||||
|
||||
const emit = () => { for (const listener of [...listeners]) listener(store); };
|
||||
const find = id => jobs.find(job => job.id === id);
|
||||
|
||||
function announce(previous, next) {
|
||||
if (previous?.state === 'running' && FINAL_STATES.has(next.state)) notify(finishToast(next));
|
||||
}
|
||||
|
||||
function closeStream(id) {
|
||||
const source = streams.get(id);
|
||||
if (!source) return;
|
||||
streams.delete(id);
|
||||
source.onmessage = null;
|
||||
source.onerror = null;
|
||||
source.close();
|
||||
}
|
||||
|
||||
function follow() {
|
||||
for (const id of [...streams.keys()]) if (find(id)?.state !== 'running') closeStream(id);
|
||||
for (const job of jobs) if (job.state === 'running' && !streams.has(job.id)) openStream(job.id);
|
||||
}
|
||||
|
||||
function openStream(id) {
|
||||
const source = connect(`${JOBS_URL}/${id}/events`);
|
||||
streams.set(id, source);
|
||||
source.onmessage = event => {
|
||||
let job;
|
||||
try { job = parseJob(JSON.parse(event.data)); } catch { job = null; }
|
||||
if (!job || job.id !== id) return;
|
||||
attempt = 0;
|
||||
upsert(job);
|
||||
};
|
||||
source.onerror = () => {
|
||||
closeStream(id);
|
||||
if (find(id)?.state === 'running') scheduleResync({ drop: true });
|
||||
};
|
||||
}
|
||||
|
||||
/** A job never runs again once it has ended, whatever an older read in flight still says. */
|
||||
const settled = (previous, next) => FINAL_STATES.has(previous?.state) && next.state === 'running' ? previous : next;
|
||||
|
||||
function upsert(job) {
|
||||
const previous = find(job.id);
|
||||
job = settled(previous, job);
|
||||
jobs = previous ? jobs.map(item => item.id === job.id ? job : item) : [job, ...jobs];
|
||||
announce(previous, job);
|
||||
follow();
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) && (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();
|
||||
}
|
||||
|
||||
function markLost() {
|
||||
jobs = jobs.map(job => job.state === 'running' ? { ...job, state: 'lost' } : job);
|
||||
follow();
|
||||
}
|
||||
|
||||
/** 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({ drop });
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** The next periodic read; a hidden tab skips its turn. */
|
||||
function schedulePoll() {
|
||||
if (disposed || suspended || pollMs <= 0) return;
|
||||
if (pollTimer !== null) timers.clearTimeout(pollTimer);
|
||||
pollTimer = timers.setTimeout(() => {
|
||||
pollTimer = null;
|
||||
if (isVisible()) load();
|
||||
else schedulePoll();
|
||||
}, pollMs);
|
||||
}
|
||||
|
||||
/** Read this session's jobs. A failed read marks running jobs ended and tries again after the backoff. */
|
||||
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.
|
||||
reading?.abort();
|
||||
const controller = new AbortController();
|
||||
reading = controller;
|
||||
try {
|
||||
const result = await api.get(JOBS_URL, { signal: controller.signal, cache: 'no-store' });
|
||||
// An older read answering late is dropped.
|
||||
if (disposed || suspended || read !== reads) return;
|
||||
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;
|
||||
failures += 1;
|
||||
if (failures >= LOST_AFTER_FAILURES) markLost();
|
||||
status = 'error';
|
||||
scheduleResync();
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
const store = {
|
||||
get status() { return status; },
|
||||
jobs: () => [...jobs],
|
||||
running: () => jobs.filter(job => job.state === 'running'),
|
||||
find,
|
||||
load,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
/** Start a job of `kind`: {job}, or {busy, message} naming the running job that holds its slot. */
|
||||
async start(kind, body = {}) {
|
||||
try {
|
||||
const result = await api.post(`${JOBS_URL}/${encodeURIComponent(kind)}`, body, { signal: scope.signal });
|
||||
const job = parseJob(result?.job);
|
||||
if (!job) throw new TypeError('Invalid job');
|
||||
upsert(job);
|
||||
notify(startToast(job));
|
||||
return { job };
|
||||
} catch (error) {
|
||||
if (error?.status !== 409 || error?.code !== 'busy') throw error;
|
||||
await load();
|
||||
return { busy: store.running()[0] ?? null, message: error.message };
|
||||
}
|
||||
},
|
||||
/** Ask a running job to stop at its next file boundary; false once it has ended. */
|
||||
async cancel(id) {
|
||||
const result = await api.post(`${JOBS_URL}/${encodeURIComponent(id)}/cancel`, {}, { signal: scope.signal });
|
||||
return result?.cancellation_requested === true;
|
||||
},
|
||||
};
|
||||
|
||||
/** Stop every timer, stream and read in flight. */
|
||||
function halt() {
|
||||
if (retryTimer !== null) timers.clearTimeout(retryTimer);
|
||||
if (pollTimer !== null) timers.clearTimeout(pollTimer);
|
||||
retryTimer = null;
|
||||
pollTimer = null;
|
||||
reading?.abort();
|
||||
reading = null;
|
||||
for (const id of [...streams.keys()]) closeStream(id);
|
||||
}
|
||||
const onPageHide = () => { suspended = true; halt(); };
|
||||
const onPageShow = event => {
|
||||
if (!suspended || !event.persisted) return;
|
||||
suspended = false;
|
||||
load();
|
||||
};
|
||||
lifecycle?.addEventListener('pagehide', onPageHide);
|
||||
lifecycle?.addEventListener('pageshow', onPageShow);
|
||||
|
||||
scope.cleanup(() => {
|
||||
disposed = true;
|
||||
halt();
|
||||
lifecycle?.removeEventListener('pagehide', onPageHide);
|
||||
lifecycle?.removeEventListener('pageshow', onPageShow);
|
||||
listeners.clear();
|
||||
});
|
||||
return store;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Activity (#249 Activity-1): the concept's Activity page (app.js renderActivity 732–738), its top-bar button
|
||||
* (`button#activity-open` with `span.count#activity-count`, index.html 51–52) and its sidebar row (app.js ~341), fed
|
||||
* by the session job store (activity-store.mjs).
|
||||
*
|
||||
* - D52: both counts show the running jobs only and are hidden at 0; the heading has no "Unified activity · proposed"
|
||||
* chip (prototype copy).
|
||||
* - D53: while a job runs, the progress pill (`a.activity-pill`, the approved mockup's) sits before the button on every
|
||||
* page: "Scanning · 42% · 8,240 / 19,622" and a mini bar, linking `#/activity/<id>`.
|
||||
* - D56: each item's title links its job's own view (activity-job-view.mjs).
|
||||
* - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running",
|
||||
* "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and
|
||||
* "Cancel job" (no "Simulate completion").
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
import { pillParts, outcomeLine, addedOf, LOST_DETAIL } from './activity-store.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.';
|
||||
export const LOAD_FAILED = 'Activity could not be read. It is tried again shortly.';
|
||||
export const CANCEL_FAILED_TOAST = 'The job could not be cancelled. Try again.';
|
||||
const CHIPS = Object.freeze({
|
||||
running: ['blue', 'Running'], finished: ['green', 'Completed'], cancelled: ['blue', 'Cancelled'],
|
||||
failed: ['blue', 'Failed'], lost: ['blue', 'Ended'],
|
||||
});
|
||||
|
||||
function element(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** "09:41" in the browser's local time; an unknown time is empty. */
|
||||
export function clockTime(seconds) {
|
||||
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return '';
|
||||
const date = new Date(seconds * 1000);
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const join = parts => parts.filter(Boolean).join(' · ');
|
||||
|
||||
/** What a job covers: "2 output folders" for a scan. */
|
||||
function scopeLine(job) {
|
||||
const folders = job.scope?.output_folders;
|
||||
return Number.isSafeInteger(folders) && folders >= 0 ? countOf(folders, 'output folder') : '';
|
||||
}
|
||||
|
||||
/** The item's line under its title, by state. */
|
||||
export function jobDetail(job) {
|
||||
const at = clockTime(job.finished_at);
|
||||
if (job.state === 'running') {
|
||||
return join([`Started ${clockTime(job.started_at)}`, scopeLine(job), 'closing this view does not cancel it']);
|
||||
}
|
||||
if (job.state === 'lost') return LOST_DETAIL;
|
||||
if (job.state === 'cancelled') {
|
||||
return join([`Cancelled ${at} at a file boundary`, job.kind === 'scan' ? addedOf(job) : '',
|
||||
'completed work is kept']);
|
||||
}
|
||||
if (job.state === 'failed') return join([`Stopped on an error at ${at}`, outcomeLine(job)]);
|
||||
return join([`Finished ${at}`, outcomeLine(job)]);
|
||||
}
|
||||
|
||||
/** The shell's Activity indicators: the top-bar count and pill, and the sidebar row's count. */
|
||||
export function mountActivityTopbar({ store, scope, doc = document }) {
|
||||
const button = doc.getElementById('activity-open');
|
||||
const count = doc.getElementById('activity-count');
|
||||
const pill = doc.getElementById('activity-pill');
|
||||
scope.listen(button, 'click', () => navigateTo('#/activity'));
|
||||
const label = element('span', '', 'activity-pill-label');
|
||||
const files = element('span', '', 'activity-pill-files');
|
||||
const bar = element('span', undefined, 'activity-pill-bar');
|
||||
const fill = element('i');
|
||||
bar.setAttribute('aria-hidden', 'true');
|
||||
bar.append(fill);
|
||||
pill.replaceChildren(label, files, bar);
|
||||
const render = () => {
|
||||
const running = store.running();
|
||||
count.textContent = String(running.length);
|
||||
count.hidden = running.length === 0;
|
||||
const row = doc.querySelector('#system-nav a[data-route="activity"]');
|
||||
row?.querySelector('.nav-count')?.remove();
|
||||
if (row && running.length) row.append(element('span', running.length.toLocaleString(), 'nav-count'));
|
||||
const [job] = running;
|
||||
pill.hidden = !job;
|
||||
if (!job) {
|
||||
pill.removeAttribute('href');
|
||||
pill.removeAttribute('aria-label');
|
||||
return;
|
||||
}
|
||||
const parts = pillParts(job);
|
||||
label.textContent = parts.label;
|
||||
files.textContent = parts.files;
|
||||
fill.style.width = `${job.progress}%`;
|
||||
pill.href = `#/activity/${job.id}`;
|
||||
pill.setAttribute('aria-label', parts.text);
|
||||
};
|
||||
scope.cleanup(store.subscribe(render));
|
||||
render();
|
||||
}
|
||||
|
||||
function itemFor(job, scope) {
|
||||
const item = element('div', undefined, 'activity-item');
|
||||
item.dataset.jobId = job.id;
|
||||
const heading = element('div', undefined, 'panel-heading');
|
||||
const title = element('h3');
|
||||
// D56: the title opens the job's own view.
|
||||
const link = element('a');
|
||||
link.href = `#/activity/${job.id}`;
|
||||
title.append(link);
|
||||
const chip = element('span');
|
||||
heading.append(title, chip);
|
||||
const detail = element('p');
|
||||
const progress = element('progress');
|
||||
progress.max = 100;
|
||||
const actions = element('div', undefined, 'row-actions');
|
||||
const cancel = createButton('Cancel job');
|
||||
actions.append(cancel);
|
||||
item.append(heading, detail, progress, actions);
|
||||
scope.listen(cancel, 'click', async () => {
|
||||
if (cancel.getAttribute('aria-disabled') === 'true') return;
|
||||
cancel.setAttribute('aria-disabled', 'true');
|
||||
try {
|
||||
await item.cancelJob();
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
cancel.removeAttribute('aria-disabled');
|
||||
showWorkspaceToast(CANCEL_FAILED_TOAST);
|
||||
}
|
||||
});
|
||||
item.update = next => {
|
||||
const [tone, state] = CHIPS[next.state];
|
||||
link.textContent = `${next.state === 'finished' ? '✓' : '○'} ${next.title}`;
|
||||
chip.className = `chip ${tone}`;
|
||||
chip.textContent = state;
|
||||
detail.textContent = jobDetail(next);
|
||||
const running = next.state === 'running';
|
||||
if (running) {
|
||||
progress.value = next.progress;
|
||||
progress.textContent = `${next.progress}%`;
|
||||
progress.setAttribute('aria-label', `${next.title} progress`);
|
||||
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();
|
||||
}
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
/** The Activity page: the concept's "Recent work" list of this session's jobs (one job's view is D56). */
|
||||
export function mountActivityPage(main, { store, scope }) {
|
||||
pageHeading(main).description.textContent = ACTIVITY_DESCRIPTION;
|
||||
const section = element('section', undefined, 'surface');
|
||||
const status = element('p', undefined, 'empty-state');
|
||||
section.append(element('h2', 'Recent work'));
|
||||
main.append(section);
|
||||
const items = new Map();
|
||||
const render = () => {
|
||||
const jobs = store.jobs();
|
||||
for (const [id, item] of items) if (!jobs.some(job => job.id === id)) { item.remove(); items.delete(id); }
|
||||
jobs.forEach((job, index) => {
|
||||
let item = items.get(job.id);
|
||||
if (!item) {
|
||||
item = itemFor(job, scope);
|
||||
item.cancelJob = () => store.cancel(job.id);
|
||||
items.set(job.id, item);
|
||||
}
|
||||
item.update(job);
|
||||
// Only a node out of place moves, so a focused Cancel job keeps its focus across updates.
|
||||
const at = section.children[index + 1];
|
||||
if (at !== item) section.insertBefore(item, at ?? null);
|
||||
});
|
||||
const failed = store.status === 'error' && !jobs.length;
|
||||
status.textContent = failed ? LOAD_FAILED : EMPTY_ACTIVITY;
|
||||
if (failed) status.setAttribute('role', 'alert');
|
||||
else status.removeAttribute('role');
|
||||
if (jobs.length || store.status === 'idle') status.remove();
|
||||
else if (!status.isConnected) section.append(status);
|
||||
};
|
||||
scope.cleanup(store.subscribe(render));
|
||||
render();
|
||||
// Opening Activity reads the list at once instead of waiting for the next periodic read.
|
||||
store.load();
|
||||
}
|
||||
@@ -3,8 +3,9 @@ const paths = Object.freeze({
|
||||
"library": "M4 4h5v16H4z M11 4h4v16h-4z M17 5l3-1 3 15-3 1z",
|
||||
"tags": "M3 3h8l10 10-8 8L3 11V3ZM7 7h.01",
|
||||
"upload": "M12 16V3m-5 5 5-5 5 5M3 15v6h18v-6",
|
||||
// D28 ruling: an outline home glyph of its own; the concept's `activity` pulse stays with the Activity row D5 omits.
|
||||
// D28 ruling: an outline home glyph of its own; the concept's `activity` pulse belongs to the Activity row.
|
||||
"overview": "M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6",
|
||||
"activity": "M3 12h4l3-8 4 16 3-8h4",
|
||||
"settings": "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2",
|
||||
"grid": "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z",
|
||||
"list": "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { noun } from './plural.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { mountToolbar } from './toolbar.mjs';
|
||||
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
|
||||
@@ -112,7 +113,7 @@ export function onFavoriteChange(listener) {
|
||||
* Images last visit.
|
||||
*/
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
|
||||
favorites = false, onSaveView = () => {}} = {}) {
|
||||
favorites = false, onSaveView = () => {}, activity = null} = {}) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
@@ -165,7 +166,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(() => importButton.remove());
|
||||
scope.listen(importButton, 'click', () => {
|
||||
importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning,
|
||||
onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan});
|
||||
onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan,
|
||||
// D55: Scan for metadata reviews the session scan job over every discovered source.
|
||||
onMetadataScan:activity ? trigger => openMetadataScan({scope, store:activity, sources:roots.length, trigger}) : null});
|
||||
});
|
||||
}
|
||||
// The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14).
|
||||
|
||||
@@ -11,6 +11,9 @@ import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading, pageHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
import { createActivityStore } from './activity-store.mjs';
|
||||
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
|
||||
import { mountActivityJobView } from './activity-job-view.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -43,7 +46,7 @@ function mountWorkspace() {
|
||||
const preference = document.getElementById('theme');
|
||||
preference.value = theme.preference;
|
||||
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
|
||||
// D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (D5: no Activity).
|
||||
// D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (a send is not an Activity job).
|
||||
let images = null;
|
||||
scope.cleanup(bindCanvasLink(document.querySelector('.topbar .canvas-link'), {
|
||||
href: canvasHref(resolveUrl, window.location.pathname),
|
||||
@@ -60,8 +63,13 @@ function mountWorkspace() {
|
||||
return link;
|
||||
});
|
||||
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
|
||||
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
|
||||
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
|
||||
// The concept's tools-and-system group: Activity, System, then (D28) the production Overview hub.
|
||||
systemNav.replaceChildren(...navLinks([['activity', 'activity'], ['system', 'settings'], ['overview', 'overview']]));
|
||||
// Activity (#249 Activity-1): one store of this session's jobs feeds the top-bar button and pill, the sidebar
|
||||
// row's count and the Activity page, on every route.
|
||||
const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) });
|
||||
mountActivityTopbar({ store: activity, scope });
|
||||
activity.load();
|
||||
const sidebar = document.querySelector('aside.sidebar');
|
||||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
|
||||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
|
||||
@@ -210,7 +218,7 @@ function mountWorkspace() {
|
||||
if (route.id === 'images' || route.id === 'favorites') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
|
||||
onSaveView:saveView('images', view)});
|
||||
onSaveView:saveView('images', view), activity});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
@@ -221,6 +229,12 @@ function mountWorkspace() {
|
||||
mountVocabulary(main, api, view);
|
||||
return;
|
||||
}
|
||||
if (route.id === 'activity') {
|
||||
// D56: `#/activity/<id>` is that job's own view; `#/activity` lists the session's jobs.
|
||||
if (route.jobId) mountActivityJobView(main, { store: activity, scope: view, api, jobId: route.jobId });
|
||||
else mountActivityPage(main, { store: activity, scope: view });
|
||||
return;
|
||||
}
|
||||
if (isHandoff(route)) {
|
||||
description.textContent = route.description;
|
||||
const panel = element('section', undefined, 'surface handoff');
|
||||
|
||||
@@ -11,11 +11,13 @@ export const routes = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
|
||||
{ id: 'favorites', label: 'Favorites' },
|
||||
// The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card.
|
||||
{ id: 'activity', label: 'Activity' },
|
||||
{ id: 'system', label: 'System', href: '/prompt_manager/admin',
|
||||
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
|
||||
];
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites']);
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites', 'activity']);
|
||||
|
||||
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
|
||||
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
|
||||
@@ -33,6 +35,9 @@ export function isHandoff(route) {
|
||||
export function resolveRoute(hash) {
|
||||
const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash);
|
||||
if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash};
|
||||
// One job of the Activity page (its id is the registry's uuid4 hex).
|
||||
const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash);
|
||||
if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash};
|
||||
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
|
||||
if (match && Number.isSafeInteger(Number(match[1]))) {
|
||||
return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash};
|
||||
@@ -110,7 +115,8 @@ export function mountRouter(win, onRoute) {
|
||||
const route = resolveRoute(win.location.hash);
|
||||
const hash = route.hash || `#/${route.id}`;
|
||||
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
|
||||
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId) return;
|
||||
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId &&
|
||||
current?.jobId === route.jobId) return;
|
||||
current = route;
|
||||
onRoute(route);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
/** 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;
|
||||
|
||||
/** Show one outcome in the shared workspace toast; it clears itself like the concept's. */
|
||||
/** 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); 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;
|
||||
toast.textContent = message;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {toast.textContent = '';}, TOAST_MS);
|
||||
releaseWait?.();
|
||||
if (Array.isArray(message)) {
|
||||
toast.replaceChildren(...message.map(part => {
|
||||
if (typeof part === 'string') return document.createTextNode(part);
|
||||
const link = document.createElement('a');
|
||||
link.href = part.href;
|
||||
link.textContent = part.text;
|
||||
return link;
|
||||
}));
|
||||
} else {
|
||||
toast.textContent = message;
|
||||
}
|
||||
const linked = Array.isArray(message) && message.some(part => typeof part !== 'string');
|
||||
scheduleClear(toast, linked ? LINK_TOAST_MS : TOAST_MS);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* mock-status note because every field is a real filter, and no "Prompt evidence" field because evidence is only
|
||||
* known per file (D14). Clear also clears the toolbar category, as the concept's does; the page applies it.
|
||||
* - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder →
|
||||
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…").
|
||||
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…"). D55: the
|
||||
* concept's own "Scan an output folder → Review scan" row follows as "Scan for metadata".
|
||||
* - "Review metadata scan" (a1428–1432 `scan-review`, D55): Sources and Scope in a `dl.meta-grid`, the concept's
|
||||
* sentence, a backup `.notice` ("Back up now") and Cancel / Start scan; no sample-plan note.
|
||||
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
|
||||
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
|
||||
* action; used by the Images selection bar's "Set category" (#249 D8) and the toolbar's Save view.
|
||||
@@ -247,16 +250,23 @@ const RESCAN_LABEL = 'Rescan';
|
||||
const RESCAN_BUSY_LABEL = 'Rescanning…';
|
||||
|
||||
/**
|
||||
* "Import & inspect" with the two real operations (D15). While `rescanBusy`, Rescan reads "Rescanning…" and is
|
||||
* "Import & inspect" with the real operations (D15). While `rescanBusy`, Rescan reads "Rescanning…" and is
|
||||
* aria-disabled, so a second press is visibly ignored. Returns `{setRescanBusy}` for the page to end that state.
|
||||
* With `onMetadataScan`, the concept's "Review scan" row follows as "Scan for metadata" (D55).
|
||||
*/
|
||||
export function openImportDialog({ scope, onInspect, onRescan, trigger = null, rescanBusy = false }) {
|
||||
export function openImportDialog({ scope, onInspect, onRescan, onMetadataScan = null, trigger = null, rescanBusy = false }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onInspect, onRescan });
|
||||
requireCallbacks({ onInspect, onRescan, ...(onMetadataScan ? { onMetadataScan } : {}) });
|
||||
const list = el('div', undefined, 'operation-list');
|
||||
const inspect = operation('Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector', onInspect);
|
||||
const rescan = operation('Scan an output folder', 'Find files added or removed in the output folders.', RESCAN_LABEL, onRescan);
|
||||
list.append(inspect.row, rescan.row);
|
||||
if (onMetadataScan) {
|
||||
// The opened review dialog replaces this one in the shell, so its Cancel returns focus to Import.
|
||||
const review = operation('Scan for metadata', 'Discover and enrich existing files using the configured sources.',
|
||||
'Review scan', () => onMetadataScan(trigger));
|
||||
list.append(review.row);
|
||||
}
|
||||
function setRescanBusy(busy) {
|
||||
rescan.action.textContent = busy ? RESCAN_BUSY_LABEL : RESCAN_LABEL;
|
||||
if (busy) rescan.action.setAttribute('aria-disabled', 'true');
|
||||
@@ -326,3 +336,55 @@ export function openContentDialog({ scope, title, nodes, onClose = null, trigger
|
||||
if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function');
|
||||
show(title, nodes, { scope, trigger, onClose });
|
||||
}
|
||||
|
||||
export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' +
|
||||
'Do not inflate run counts or recreate existing thumbnails.';
|
||||
const BACKUP_LABEL = 'Back up now';
|
||||
const BACKUP_BUSY_LABEL = 'Backing up…';
|
||||
|
||||
/**
|
||||
* The concept's "Review metadata scan" (`scan-review`, D55). `sources` is the number of output folders the scan reads;
|
||||
* the scope is fixed to "All files" in Activity-1. `onBackup` runs the database backup (the button is aria-disabled
|
||||
* and reads "Backing up…" until it settles, and the dialog stays open); Start scan closes the dialog, then `onStart`.
|
||||
*/
|
||||
export function openScanReviewDialog({ scope, sources, onBackup, onStart, trigger = null }) {
|
||||
requireScope(scope);
|
||||
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('en-US')} output ${sources === 1 ? 'folder' : 'folders'}`],
|
||||
['Scope', 'All files']]) {
|
||||
const entry = el('div');
|
||||
entry.append(el('dt', term), el('dd', value));
|
||||
grid.append(entry);
|
||||
}
|
||||
const notice = el('div', undefined, 'notice');
|
||||
const copy = el('div');
|
||||
copy.append(el('h3', 'Back up the database first (recommended)'),
|
||||
el('p', 'Downloads a copy of the prompts database before the scan changes any records.'));
|
||||
const backupActions = el('div', undefined, 'row-actions');
|
||||
const backup = createButton(BACKUP_LABEL);
|
||||
backupActions.append(backup);
|
||||
notice.append(copy, backupActions);
|
||||
backup.addEventListener('click', async () => {
|
||||
if (backup.getAttribute('aria-disabled') === 'true') return;
|
||||
backup.setAttribute('aria-disabled', 'true');
|
||||
backup.textContent = BACKUP_BUSY_LABEL;
|
||||
try {
|
||||
await onBackup();
|
||||
} finally {
|
||||
backup.removeAttribute('aria-disabled');
|
||||
backup.textContent = BACKUP_LABEL;
|
||||
}
|
||||
});
|
||||
const cancel = createButton('Cancel');
|
||||
const start = createButton('Start scan', 'ca-primary');
|
||||
cancel.addEventListener('click', () => finish());
|
||||
start.addEventListener('click', () => {
|
||||
finish();
|
||||
onStart();
|
||||
});
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, start);
|
||||
show('Review metadata scan', [grid, el('p', SCAN_REVIEW_SENTENCE, 'small'), notice, actions], { scope, trigger });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,58 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
|
||||
}
|
||||
|
||||
/* D53: the running job's progress pill before the Activity button (the approved Activity mockup's `.pill`), in the
|
||||
concept's tokens. At <=700px it keeps its verb and percentage; the file counts and bar stay in its accessible name. */
|
||||
.activity-pill:not([hidden]) { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--line);
|
||||
border-radius: 999px; font-size: 12px; white-space: nowrap; color: var(--ca-text); background: var(--ca-surface); /* D53 */ }
|
||||
.activity-pill:hover { border-color: var(--ca-accent); /* D53 */ }
|
||||
.activity-pill-bar { display: block; inline-size: 70px; block-size: 5px; border-radius: 3px; background: var(--line); overflow: hidden; /* D53 */ }
|
||||
.activity-pill-bar i { display: block; block-size: 100%; inline-size: 0; background: var(--ca-accent); /* D53 */ }
|
||||
@media (pointer: coarse) { .activity-pill:not([hidden]) { min-block-size: 40px; /* D53 */ } }
|
||||
@media (max-width: 700px) {
|
||||
/* D52: with the Activity button the full-width row can be wider than a zoomed phone, so its items wrap. */
|
||||
.top-actions { flex-wrap: wrap; /* D52 */ }
|
||||
.activity-pill:not([hidden]) { gap: 0; padding-inline: 10px; /* D53 */ }
|
||||
.activity-pill-files, .activity-pill-bar { display: none; /* D53 */ }
|
||||
}
|
||||
|
||||
/* D56: each Activity item's title links its job's view in the title's own colour. */
|
||||
.activity-item h3 a { color: inherit; text-decoration: none; /* D56 */ }
|
||||
.activity-item h3 a:hover { text-decoration: underline; /* D56 */ }
|
||||
|
||||
/* D56: one job's view, the approved Activity mockup (activity-scan.html) in the concept's tokens: the counter tiles,
|
||||
the outcome list beside the selected outcome's files, and the collapsible log. */
|
||||
.stat-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin: 16px 0; /* D56 */ }
|
||||
.stat-row .stat { border: 1px solid var(--line); border-radius: 7px; padding: 12px 14px; background: var(--ca-surface); min-inline-size: 0; /* D56 */ }
|
||||
.stat-row .stat dt { font-size: 11px; color: var(--ca-muted); /* D56 */ }
|
||||
.stat-row .stat dd { margin: 6px 0 0; font-size: 20px; font-weight: 600; /* D56 */ }
|
||||
.stat-row .stat.added dd { color: var(--green); /* D56 */ }
|
||||
.stat-row .stat.warn dd { color: var(--warning); /* D56 */ }
|
||||
.stat-row .stat.bad dd { color: var(--red); /* D56 */ }
|
||||
.surface > .panel-heading > .search-field { max-width: 320px; /* D56 */ }
|
||||
.outcomes { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 16px; margin-top: 6px; /* D56 */ }
|
||||
.ca-tools .outcome-list button { display: flex; justify-content: space-between; width: 100%; margin-bottom: 6px; text-align: left; /* D56 */ }
|
||||
.ca-tools .outcome-list button[aria-pressed="true"] { border-color: var(--ca-accent); background: var(--soft-blue); /* D56 */ }
|
||||
.outcome-files .file-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 9px 4px;
|
||||
border-bottom: 1px solid var(--line); font-size: 12px; /* D56 */ }
|
||||
.ca-tools button.file-row { width: 100%; min-height: 0; justify-content: normal; align-items: start; border: 0;
|
||||
border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: inherit; text-align: left;
|
||||
white-space: normal; font: inherit; font-size: 12px; /* D56 */ }
|
||||
.ca-tools button.file-row:hover:not(:disabled) { background: var(--ca-hover); /* D56 */ }
|
||||
.file-row .path { font-family: ui-monospace, monospace; overflow-wrap: anywhere; /* D56 */ }
|
||||
.file-row .why { color: var(--ca-muted); /* D56 */ }
|
||||
.outcome-files > p.small { color: var(--ca-muted); margin-top: 10px; /* D56 */ }
|
||||
.term { margin-top: 16px; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; /* D56 */ }
|
||||
.term summary { padding: 11px 14px; cursor: pointer; font-size: 12px; background: var(--ca-surface); /* D56 */ }
|
||||
.term summary span { color: var(--ca-muted); /* D56 */ }
|
||||
.term pre { margin: 0; padding: 12px 14px; max-height: 180px; overflow: auto; font: 11px/1.7 ui-monospace, monospace;
|
||||
color: var(--ca-muted); background: var(--page); white-space: pre-wrap; overflow-wrap: anywhere; /* D56 */ }
|
||||
@media (max-width: 700px) {
|
||||
.stat-row { grid-template-columns: repeat(2, minmax(0, 1fr)); /* D56 */ }
|
||||
.outcomes { grid-template-columns: minmax(0, 1fr); /* D56 */ }
|
||||
.outcome-files .file-row { grid-template-columns: minmax(0, 1fr); gap: 4px; /* D56 */ }
|
||||
}
|
||||
|
||||
/* Overview: real library statistics and the legacy tool cards inside a concept `.surface`. */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; }
|
||||
.stat { padding: 22px; background: var(--ca-surface); border: 1px solid var(--line); border-radius: 7px; }
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="breadcrumbs">ComfyAssets <span>/</span> <span id="workspace-breadcrumb">Images</span></div>
|
||||
<div class="top-actions"><label class="theme-label" for="theme">Theme
|
||||
<div class="top-actions"><!-- D53: the progress pill, shown while a job runs. D52: the count shows running jobs, hidden at 0. --><a id="activity-pill" class="activity-pill" hidden></a><button id="activity-open" type="button">Activity <span class="count" id="activity-count" hidden>0</span></button><label class="theme-label" for="theme">Theme
|
||||
<select id="theme" aria-label="Theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
|
||||
Reference in New Issue
Block a user