- Store: one failed list read no longer marks running jobs as ended; two in a row, or a good list without them, do (M1). A good read resets the backoff, except for a stream that keeps dropping while the list reads fine (M2). Finished jobs the server pruned are dropped (M3). Numbers group as en-US, like the server's lines (M13). - Job view: a job the store's list no longer has shows the not-found state (M3). Focus moves to the job heading when Cancel scan goes away, and to the item's title link in the list (M4). The detail is read only when its listed rows change or the open log grows, using the summary's new listed and log_length (M12). - Viewer rows open by exact identity: each scan row records its root's listing index, the source must still carry the row's label, and the workspace listing gains an exact `path` filter (M8, M9). - Scan rows name the prompt by its Library title, stored title first (M10). - A toast with a link stays 20 s, and any toast stays while hovered or focused (M14). - A real two-batch scan cancelled after batch 1 keeps partial counts (M7). - TESTING.md: D54, D56 and D57 updated (M5, M6).
193 lines
8.7 KiB
JavaScript
193 lines
8.7 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|