Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/activity.mjs
T
Vito 7511a0fd38 fix(activity): no Cancel on jobs that ended before Activity opened (#306)
* fix(activity): drop Cancel job from a job that had ended before Activity opened

Since #289 the Activity list removed an item's actions only when they were
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 job's progress was moved after its actions, then back). The list
now asks parentNode instead. The review spec "a scan job with review items
offers Review metadata in Activity" failed on this before the change
(Expected 0 buttons on a finished job, Received 1) and passes after it.

* test(backup): wait for the restore, not a 5 s timer, in the thread test

test_database_is_usable_from_every_thread_after_restore let its worker
wait 5 s for the restore. On a stalled Windows runner (py3.11, where the
previous restore test also took 25 s instead of 0.3 s) the restore took
longer, the wait timed out, and the worker read the old database before
the restore ran. The worker now waits for the event under a 120 s bound
that only a hung test reaches, reports a timeout as an error, and the
main thread releases it even when the restore raises. With a restore
delayed by 6 s the old test fails and the new one passes.

* test(activity): no Cancel job on jobs that ended before Activity opened

Since #289 a list item dropped its actions only when they were isConnected,
so the first render of jobs the store already knew (Activity opened from
another page) kept "Cancel job" on a finished or cancelled job and moved a
running job's progress after its actions. Activity's own list read renders
again a moment later and hides it, so the new spec holds that read and checks
the first render: no progress or Cancel job on the ended jobs, and a running
job's children in order heading, detail, progress, actions.

With the parentNode fix reverted it fails on chromium and touch (Expected 0,
Received 1 on the finished job); with it, it passes.
2026-10-23 06:29:45 -07:00

208 lines
9.7 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").
* - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors".
*/
import { createButton } from '../shared/toolkit/core.js';
import { navigateTo } from './state.mjs';
import { pageHeading } from './heading.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { countOf, noun } from './plural.mjs';
import { pillParts, outcomeLine, addedOf, taggedOf, LOST_DETAIL } from './activity-store.mjs';
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(' · ');
const whole = value => Number.isSafeInteger(value) && value >= 0;
/** What a job covers: "2 output folders" for a scan, "4 images" for image tagging (D89). */
function scopeLine(job) {
// D89: grouped as the server writes its lines ("19,622 images").
const images = job.scope?.images;
if (job.kind === 'image-tags') return whole(images) ? `${images.toLocaleString('en-US')} ${noun(images, 'image')}` : '';
const folders = job.scope?.output_folders;
return whole(folders) ? countOf(folders, 'output folder') : '';
}
/** What a cancelled job kept: "N added" for a scan, "N tagged" for image tagging (D89). */
const KEPT = Object.freeze({ __proto__: null, scan: addedOf, 'image-tags': taggedOf }); // D89
const keptOf = job => KEPT[job.kind]?.(job) ?? '';
/** 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;
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';
// parentNode, not isConnected: on the first render the item is not in the page yet, so a job that had already
// ended when Activity opened kept its Cancel job (and a running one's progress moved after its actions).
if (running) {
progress.value = next.progress;
progress.textContent = `${next.progress}%`;
progress.setAttribute('aria-label', `${next.title} progress`);
if (!progress.parentNode) item.insertBefore(progress, actions.parentNode ? actions : null);
} else {
progress.remove();
}
// Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel job.
if (running && next.cancellable) {
if (!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();
}
};
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();
}