Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/activity.mjs
T
Vito 6505d339aa feat(system): thumbnails on Activity (#311)
* feat(system): run thumbnail generation as an Activity job

GET /prompt_manager/images/thumbnails/plan counts what a run would do
(targets, fresh, remaining past the cap, source labels) and writes
nothing. POST /prompt_manager/jobs/thumbnails {mode, size, quality}
starts the "thumbnails" job: admission guarded, cancellable at a file
boundary, outcome groups generated / skipped / errors. size is the
longest edge (128-2048), quality the encoder, and rebuild writes fresh
previews again through the same atomic rename, so a failed file keeps
its old preview and originals are only read.

The legacy gallery stream now starts or attaches to the same job and
streams its unchanged frames. A client that goes away no longer stops
the work; the gallery's Cancel asks the server to cancel the job.

* feat(activity): per-kind job descriptors

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

Any other kind (the health check, a held legacy-page operation) gets
the generic descriptor with no tiles and no Results, instead of the
scan's zero tiles. A running job without a known total shows an
indeterminate progress bar and no percentage, and only a job the
server can cancel shows Cancel.

* feat(system): review and start thumbnail jobs

Sources' Thumbnail generation -> Review options and Overview's Review
thumbnails open the concept's Thumbnail maintenance dialog: scope, quality
(preselected from Image loading quality) and preview size limit, a plan
line from the read-only thumbnail plan (D99) and Start, which starts the
thumbnails Activity job and opens it. A size outside 128-2048 requests
nothing; a refused start shows the busy link toast.

* fix(system): thumbnail job review fixes (#249 S5)

- The legacy stream's first frame is job {id, started}, sent after the
  subscription so no runner frame is missed; it reads the registry after
  prepare, so a job started in that gap is attached to, not refused.
- Every exit sets a truthful final status line; nothing to do never
  reads "Processing file 0 of 0".
- Cancel is checked per file during the planning walk; a job cancelled
  there publishes no start frame.
- The ffmpeg fallback keeps the chosen quality (-q:v) and the aspect
  ratio; the placeholder uses the chosen JPEG settings.
- A locked preview on Windows reads "Preview in use by another program;
  the old one was kept", not the raw WinError.
- Thumbnail work no longer defers the monitor's Needs review recording:
  it writes previews only, never records.
- The disconnect test now proves the job outlives its stream (one worker,
  9 files, three batches); the attach test waits on an event.

* fix(gallery): cancel the stream's own thumbnail job by id

The settings panel and the startup dialog cancel the job named by the
stream's job frame, and only one their stream started. A Cancel before
the frame waits for it, a double Cancel posts once, and a job another
tab or System started keeps running. The startup dialog now settles on
Cancel instead of staying on "Cancelling...".

* fix(activity): indeterminate pill bar and the nothing-to-do line

The pill's mini bar sweeps while a job's total is unknown instead of
showing an empty fill. A finished thumbnail job with nothing to do shows
the server's final line. Coverage floors raised for gallery.js and
activity.mjs.
2026-10-23 16:58:06 -07:00

219 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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").
* - D87 (plan D64): an ended scan that recorded Needs review items carries the concept's **Review metadata**
* (`go-review`, app.js a738) after its line, which opens `#/review`; its chip still names its state (D54).
* - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors".
* - D111: what a line says (scope, counters, what a cancel kept) is the job kind's descriptor (activity-kinds.mjs); a
* job without a known total shows an indeterminate `<progress>`, and only a job the server can stop shows Cancel.
*/
import { createButton } from '../shared/toolkit/core.js';
import { navigateTo } from './state.mjs';
import { pageHeading } from './heading.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { pillParts, outcomeLine, LOST_DETAIL } from './activity-store.mjs';
import { viewOf, canCancel, showProgress, indeterminate } from './activity-kinds.mjs';
export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.';
export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.';
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.';
/** Whether an ended job recorded items for Needs review (a metadata scan's `scope.review_items`). */
export function hasReviewItems(job) {
const count = job?.scope?.review_items;
return job?.state !== 'running' && Number.isSafeInteger(count) && count > 0;
}
/** The concept's **Review metadata** (D87): opens Needs review. */
export function createReviewMetadataButton(scope) {
const button = createButton('Review metadata');
scope.listen(button, 'click', () => navigateTo('#/review'));
return button;
}
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", "4 images") and what a cancelled one kept ("N added"), by kind (D111). */
const scopeLine = job => viewOf(job).scope(job);
const keptOf = job => viewOf(job).kept(job);
/** The item's line under its title, by state. */
export function jobDetail(job) {
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`, keptOf(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;
// D111 (S5 review M5): no total, no fill: the bar sweeps instead of showing an empty determinate 0%.
const unknown = indeterminate(job);
bar.classList.toggle('indeterminate', unknown);
fill.style.width = unknown ? '' : `${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);
const review = createReviewMetadataButton(scope); // D87
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';
// parentNode, not isConnected: on the first render the item is not in the page yet, so a job that had already
// ended when Activity opened kept its Cancel job (and a running one's progress moved after its actions).
if (running) {
showProgress(progress, next); // D111: no value while the total is unknown
progress.setAttribute('aria-label', `${next.title} progress`);
if (!progress.parentNode) item.insertBefore(progress, actions.parentNode ? actions : null);
} else {
progress.remove();
}
// Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel job.
if (canCancel(next)) {
if (!actions.parentNode) item.append(actions);
} else if (actions.parentNode) {
// A focused Cancel job that goes away hands its focus to the item's title link, never to the page body.
if (actions.contains(document.activeElement)) link.focus();
actions.remove();
}
if (hasReviewItems(next)) { if (!review.isConnected) detail.after(review); } // D87
else review.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();
}