Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/activity-kinds.mjs
T
Vito 9ab25f9aaa feat(system): Maintenance UI (System S7) (#322)
* fix(gallery): clear only Prompt Manager cache entries (#222)

The legacy gallery's Clear cache deleted every localStorage key starting
pm_ or promptManager_ (the workspace theme and the dismissed update notice
among them) and every CacheStorage cache whose name held gallery or
thumbnail, which on ComfyUI's shared origin can belong to another tool.

web/js/cache-ownership.js names what a clear may remove from an inventory
of the real writers: every key Prompt Manager writes is a preference and no
page opens CacheStorage or IndexedDB, so nothing is removed and the toast
says so. A test fails when a new storage writer is not classified. The
server thumbnail clear stays its own request; a refused or unreachable
server changes nothing and is reported, and refused browser storage is
reported as such.

* feat(system): run reviewed maintenance natively

System › Maintenance is native: the concept's notice and its Database and
Images & cache surfaces replace the hand-off.

- Integrity & relationship checks runs the health check; the report's
  Review missing links reviews a fresh Missing-file links plan.
- Review plan shows the server's read-only plan (D100): scope, protected,
  backup policy and media lines, the listed records, how long the review
  holds and the backup box; Apply reviewed operation sends only its token
  and opens the job in Activity. busy names the running job, plan-used links
  the job that applied it, an expired or replaced review is reviewed again,
  and a lost answer says Outcome not confirmed and points to Activity.
- Find duplicates starts the search; its view offers Review groups (D101):
  ten groups at a time, Keep radios per copy, each keeper sent as
  {group_id, root_index, path}; the dialog says History keeps the removed
  paths in its run records.
- Thumbnail cache opens the thumbnail dialog; Metadata cache is disabled
  with its reason while nothing is owned (D102, Q5).
- Activity descriptors for maintenance, duplicates and duplicate_cleanup.
- The legacy duplicates dialog's View Full names its source (?root=).

* test(system): compare Maintenance with the concept

system-maintenance-concept.spec compares System › Maintenance, the plan
dialog of each operation and the duplicates dialog with the concept in both
themes and both viewports, mapped only by D100–D102, and proves a mutated
row, label, danger mark or token is noticed. The duplicates dialog's Review
duplicate cleanup is the concept's plain button.

* test(system): Maintenance no longer hands off; raise coverage floors

The System shell test expected Maintenance's D94 hand-off; the section is
native now. coverage:update sets the new modules' floors from the
measured run (cache-ownership 94, maintenance-review 94,
system-maintenance 92 lines) and raises the floors other files passed.

* test(admin): close the preview worker after the last job in teardown

A scan publishes its last event, which ends the stream the test reads,
before it hands its missing previews to the automatic worker. That
hand-off can run after tearDownAsync closed the worker, so it starts a new
one whose .part files were still open when the final drain checked for
open handles (py3.10 on ubuntu, test_scan_reads_metadata_on_several_threads).
The drain now closes the worker for good once every job has ended.

* fix(system): address the S7 final review

- I1: an apply answer is a refusal only when our server sent it (a known
  code, or a 4xx whose body says success:false); a proxy's 5xx, an
  unreadable 2xx or a dropped connection says Outcome not confirmed and
  reads Activity again. ApiError gains 'refused'; the server's own
  pre-start failure is coded 'apply-failed'.
- M1: the lost-response test lets the apply run, then drops its answer.
- M2: a failed apply records its code in the job's scope; Activity offers
  Review again for an overtaken plan (Maintenance for a duplicate cleanup
  whose sources changed).
- M3: a failed duplicate cleanup toasts the server's reason.
- M4: a review button holds while its preview is read (one preview per
  double press).
- M5: the storage guard also catches bracket and property assignment and
  CacheStorage/IndexedDB opens; why the Toolkit build is skipped is
  written down.
- M6: a refused storage listing says so on the Metadata cache row.
- M7, M8: D100 text corrected; workflow_storage has its title.
- M9: a busy start names the job the server named.

* test: follow #319 in the Maintenance tests

- The thumbnail dialog has only Scope since #319.
- The admin teardown refuses new preview workers on its own API instead
  of closing with final=True, which since #319 also shuts the process-wide
  encoders that later test modules use.

* fix(system): keep the server's search-again line on a duplicate review

A duplicate cleanup review the server refuses with 409 sources-changed
showed the generic "could not be reviewed" message; it now shows the
server's "The sources changed since the search. Search for duplicates
again."

* fix(system): a plan with nothing to do offers only Close

A reviewed maintenance plan whose scope is 0 records ("No duplicate
prompt text") offered Apply reviewed operation, which started an empty
job. It now shows its rows, "There is nothing to apply; nothing was
changed." and a Close button.

* fix(system): wrap long copy paths in the duplicates review

A Keep label is the copy's source and path, and dataset folders often have
long names with no space to break at, so the review scrolled sideways.
The label now wraps anywhere inside its compare item (D101).
2026-10-25 05:51:57 -07:00

249 lines
12 KiB
JavaScript

/**
* Per-kind Activity descriptors (#249 System S5, D111): one table that says, for each job kind, what Activity shows.
*
* `kindOf(kind)` → `{pillVerb, startToast(job), finishToast(job), line(job), kept(job), scope(job), tiles, outcomes,
* openable, log, cancelLabel}`:
* - `pillVerb`: the pill's verb ("Scanning"); null shows the job's title.
* - `startToast` / `finishToast`: what a toast says when this tab starts the job and when it ends.
* - `line`: the counters as one line ("N added · M errors"); `kept`: what a cancelled job kept ("N added"); `scope`:
* what the job covers ("2 output folders").
* - `tiles` (`[group, label, tone]`), `outcomes` (`[group, label]`) and `openable` (rows that open the viewer, D57).
* - `log` and `cancelLabel` (`cancel` is the same label under the job view's older name).
*
* `scan` is exactly the D56 view and `image-tags` the D89 one. `thumbnails` counts Generated (green), Skipped and
* Errors (red). System S7: `maintenance` (a reviewed plan's apply) counts Changed, Protected and Errors and, when it
* failed, ends with the server's reason; `duplicates` (the search) counts Groups found; `duplicate_cleanup` counts
* Removed, Kept and Errors. Any other kind (the health check, a held legacy-page operation, a kind this page does not know) gets
* the generic descriptor: title-based copy and no tiles or outcomes, so it never shows another kind's zeros (S1 review
* M6, S2 review M3). `viewOf(job)` keeps only the groups the job itself counts, so a hold (no groups) has none either.
*
* Progress is indeterminate while a job runs without a known total (`indeterminate`): the `<progress>` has no value
* and the pill shows no percentage, never a made-up 0%. A job shows Cancel only while it runs and the server can stop
* it (`canCancel`).
*/
import { noun, countOf } from './plural.mjs';
// 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 counted = (job, group, word) => `${number(job.counters?.[group] ?? 0)} ${word}`;
const errorsOf = job => `${number(job.counters?.errors ?? 0)} ${noun(job.counters?.errors ?? 0, 'error')}`;
const whole = value => Number.isSafeInteger(value) && value >= 0;
const tiles = rows => Object.freeze(rows.map(Object.freeze));
const groups = (...names) => Object.freeze(new Set(names));
/** "1,420 added". */
export const addedOf = job => counted(job, 'added', 'added');
/** "12 tagged" (D89). */
export const taggedOf = job => counted(job, 'tagged', 'tagged');
/** "3 generated" (D111). */
export const generatedOf = job => counted(job, 'generated', 'generated');
/** "3 changed" (System S7: a maintenance apply). */
export const changedOf = job => counted(job, 'changed', 'changed');
/** "12 groups found" (System S7: a duplicate search). */
export const groupsOf = job => {
const groups = job.counters?.groups_found ?? 0;
return `${number(groups)} ${noun(groups, 'group')} found`;
};
/** "3 removed" (System S7: a duplicate cleanup). */
export const removedOf = job => counted(job, 'removed', 'removed');
/** "2 output folders": what a scan or a thumbnail job walks. */
function foldersOf(job) {
const folders = job.scope?.output_folders;
return whole(folders) ? countOf(folders, 'output folder') : '';
}
/** "4 images" (D89), grouped as the server writes its lines. */
function imagesOf(job) {
const images = job.scope?.images;
return whole(images) ? `${number(images)} ${noun(images, 'image')}` : '';
}
const titleStarted = job => `${job.title} started.`;
function titleFinished(job) {
if (job.state === 'cancelled') return `${job.title} was cancelled.`;
if (job.state === 'failed') return `${job.title} failed.`;
return `${job.title} finished.`;
}
/** "<Noun> finished · <line>", "<Noun> cancelled · <kept>", "<Noun> failed · <line>". */
const namedFinish = (name, kept, line) => job => {
if (job.state === 'cancelled') return `${name(job)} cancelled · ${kept(job)}`;
return `${name(job)} ${job.state === 'failed' ? 'failed' : 'finished'} · ${line(job)}`;
};
const nothing = () => '';
const GENERIC = Object.freeze({
pillVerb: null, startToast: titleStarted, finishToast: titleFinished, line: nothing, kept: nothing,
scope: foldersOf, tiles: tiles([]), outcomes: tiles([]), openable: groups(), log: 'Job log', cancelLabel: 'Cancel job',
cancel: 'Cancel job',
});
const scanLine = job => `${addedOf(job)} · ${errorsOf(job)}`;
const tagLine = job => `${taggedOf(job)} · ${errorsOf(job)}`;
const thumbnailLine = job => `${generatedOf(job)} · ${errorsOf(job)}`;
const changedLine = job => `${changedOf(job)} · ${errorsOf(job)}`;
const cleanupLine = job => `${removedOf(job)} · ${errorsOf(job)}`;
/** A maintenance apply ends with its counters, or, when it failed, the server's reason (plan-changed: review again). */
function maintenanceFinish(job) {
if (job.state === 'cancelled') return `${job.title} cancelled · ${changedOf(job)}`;
if (job.state === 'failed') return `${job.title} failed · ${job.status_line || errorsOf(job)}`;
return `${job.title} finished · ${changedOf(job)}`;
}
// D85: an image tagging job says which of its two modes started.
const IMAGE_TAG_START_TOASTS = Object.freeze({ add: 'Tagging started.', auto: 'Auto-tagging started.' });
/** Every kind with a descriptor of its own. */
export const KINDS = Object.freeze({
__proto__: null,
// D56: the scan's tiles and outcome list in the mockup's orders; what got added and what went wrong first.
scan: Object.freeze({
...GENERIC,
pillVerb: 'Scanning',
startToast: () => 'Scan started.',
finishToast: namedFinish(() => 'Scan', addedOf, scanLine),
line: scanLine,
kept: addedOf,
tiles: tiles([['added', 'Added to Library', 'added'], ['known', 'Already known', ''],
['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]),
outcomes: tiles([['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'],
['no_metadata', 'No usable metadata'], ['known', 'Already known']]),
openable: groups('added', 'known'),
log: 'Scan log',
cancelLabel: 'Cancel scan',
cancel: 'Cancel scan',
}),
// D89: Tagged (green), Unchanged, Skipped (warning) and Errors (red); Tagged and Unchanged rows open the viewer.
'image-tags': Object.freeze({
...GENERIC,
pillVerb: 'Tagging',
startToast: job => IMAGE_TAG_START_TOASTS[job.scope?.mode] ?? titleStarted(job),
finishToast: namedFinish(job => job.title, taggedOf, tagLine),
line: tagLine,
kept: taggedOf,
scope: imagesOf,
tiles: tiles([['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'],
['errors', 'Errors', 'bad']]),
outcomes: tiles([['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['unchanged', 'Unchanged']]),
openable: groups('tagged', 'unchanged'),
log: 'Tagging log',
cancelLabel: 'Cancel tagging',
cancel: 'Cancel tagging',
}),
// D111: Generated (green), Skipped (already fresh) and Errors (red); a row with a preview opens the viewer.
thumbnails: Object.freeze({
...GENERIC,
pillVerb: 'Generating thumbnails',
finishToast: namedFinish(() => 'Thumbnails', generatedOf, thumbnailLine),
line: thumbnailLine,
kept: generatedOf,
tiles: tiles([['generated', 'Generated', 'added'], ['skipped', 'Skipped', ''], ['errors', 'Errors', 'bad']]),
outcomes: tiles([['generated', 'Generated'], ['errors', 'Errors'], ['skipped', 'Skipped']]),
openable: groups('generated', 'skipped'),
}),
// System S7: a reviewed maintenance plan's apply, titled after its operation; it runs without a known total.
maintenance: Object.freeze({
...GENERIC,
finishToast: maintenanceFinish,
line: changedLine,
kept: changedOf,
tiles: tiles([['changed', 'Changed', 'added'], ['protected', 'Protected', ''], ['errors', 'Errors', 'bad']]),
outcomes: tiles([['changed', 'Changed'], ['errors', 'Errors'], ['protected', 'Protected']]),
log: 'Maintenance log',
}),
// System S7: the duplicate image search; each found group's row opens its first copy.
duplicates: Object.freeze({
...GENERIC,
pillVerb: 'Finding duplicates',
startToast: () => 'Duplicate search started.',
finishToast: namedFinish(() => 'Duplicate search', groupsOf, groupsOf),
line: groupsOf,
kept: groupsOf,
tiles: tiles([['groups_found', 'Groups found', '']]),
outcomes: tiles([['groups_found', 'Groups found']]),
openable: groups('groups_found'),
log: 'Search log',
cancelLabel: 'Cancel search',
cancel: 'Cancel search',
}),
// System S7: the reviewed duplicate cleanup; the kept copies open the viewer, the removed ones are gone.
duplicate_cleanup: Object.freeze({
...GENERIC,
pillVerb: 'Removing duplicate files',
// A failed cleanup says the server's reason (sources changed, no safety backup), as a maintenance apply does.
finishToast: job => (job.state === 'failed' && job.status_line ? `Duplicate cleanup failed · ${job.status_line}`
: namedFinish(() => 'Duplicate cleanup', removedOf, cleanupLine)(job)),
line: cleanupLine,
kept: removedOf,
tiles: tiles([['removed', 'Removed', 'added'], ['kept', 'Kept', ''], ['errors', 'Errors', 'bad']]),
outcomes: tiles([['removed', 'Removed'], ['errors', 'Errors'], ['kept', 'Kept']]),
openable: groups('kept'),
log: 'Cleanup log',
}),
});
/** The descriptor of a job `kind`; any kind without one gets the generic descriptor. */
export function kindOf(kind) {
return (typeof kind === 'string' && KINDS[kind]) || GENERIC;
}
const countsOf = job => job.counters && typeof job.counters === 'object' ? job.counters : {};
/**
* A legacy-page operation held on the registry (D112): not cancellable and counting no outcome groups. Whatever its
* kind (the gallery's thumbnail clear is a `thumbnails` hold), it reads as the generic job under its own title.
*/
export const held = job => job.cancellable === false && Object.keys(countsOf(job)).length === 0;
/**
* The descriptor for one job: its kind's, keeping only the outcome groups the job counts; a held job's is the generic
* one.
*/
export function viewOf(job) {
if (held(job)) return GENERIC;
const view = kindOf(job.kind);
const counts = countsOf(job);
const has = ([group]) => Object.hasOwn(counts, group);
if (view.tiles.every(has) && view.outcomes.every(has)) return view;
return Object.freeze({ ...view, tiles: tiles(view.tiles.filter(has)), outcomes: tiles(view.outcomes.filter(has)),
openable: groups(...[...view.openable].filter(group => Object.hasOwn(counts, group))) });
}
/** A running job whose total the server has not reported (yet) has no percentage. */
export function indeterminate(job) {
return job.state === 'running' && !(job.total > 0);
}
/** Set a `<progress>` to the job's: its value and "N%", or no value at all while indeterminate. */
export function showProgress(node, job) {
if (indeterminate(job)) {
node.removeAttribute('value');
node.textContent = '';
return;
}
node.value = job.progress;
node.textContent = `${job.progress}%`;
}
/** Truth rule: Cancel only while the job runs and the server can stop it (never on a held legacy job). */
export function canCancel(job) {
return job.state === 'running' && job.cancellable !== false;
}
/** The pill's text: "Scanning · 42% · 8,240 / 19,622"; the file counts once the total is known, no "%" before. */
export function pillParts(job) {
const verb = viewOf(job).pillVerb ?? job.title;
const label = indeterminate(job) ? verb : `${verb} · ${job.progress}%`;
const files = job.total > 0 ? ` · ${number(job.processed)} / ${number(job.total)}` : '';
return { label, files, text: label + files };
}
/** The toast after this tab starts a job. */
export const startToast = job => viewOf(job).startToast(job);
/** The toast when a job ends. */
export const finishToast = job => viewOf(job).finishToast(job);
/** The counters as one line; '' for a kind without one. */
export const outcomeLine = job => viewOf(job).line(job);