Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/maintenance-review.mjs
T
Vito bdc38422a9 feat(system): Diagnostics & logs natively, reviewed log cleanup and Clear all logs (#329, PR 2) (#385)
* fix(logs): declare the compressed length so a log download finishes

The gzip middleware compressed a text body but kept the handler's declared
Content-Length (the uncompressed size), so a browser waited for bytes that
never came: downloading any log over 1 KB hung, in the workspace and on the
admin page. The middleware now drops a declared length before compressing.

Part of #329

* feat(system): show Diagnostics & logs natively and retire the hand-off panel

System › Diagnostics & logs is native (D109, D110): the concept's
Diagnostics surface with the live database, source, Viewer.js and renderer
lines, Run checks (the shared Health report), the diagnostic report, Inspect
and the confirmed test link; and the Logs surface with the minimum-level
viewer, Lines 1-500, statistics, the log download, the runtime level and
console setting saved by Save preferences, the log files with their
downloads, Review log cleanup (the rotated_logs maintenance plan, no backup
box, Listed files) and a confirmed Clear all logs.

D94's hand-off panel, SECTION_HANDOFFS and the undelivered runtime adapter
are gone. The browser server logs into its own temporary root.

Part of #329

* fix(system): confirm Clear all logs on a fresh file list and record coverage

Review fixes for the Diagnostics & logs section:
- Clear all logs reads the log files again before it confirms, so the
  dialog names the copies it deletes now (a list it cannot read gives the
  general sentence, never an old count); Refresh reads the files too.
- Runtime values that cannot be read stay disabled and say so.
- Create test link holds its button until the write answered, so a second
  press cannot write a second record.
- The Lines field names its range.
- The browser server builds its logger in its own root, so no log file is
  ever opened in another folder; the fixture closes every directory scan
  and replaces a link instead of writing through it.
- system.mjs drops the unused section fallback; frontend coverage floors
  recorded with coverage:update (system-diagnostics.mjs 97.5% lines).

Part of #329

* fix(system): read the database size the report sends

The Database line read database_info.size_bytes, a key the report does
not have (get_database_info answers database_size_bytes), so it always
read "Readable · 0 bytes · N images". The browser test now requires a
non-zero size. Found by PR 3's concept comparison.

Part of #329
2026-10-27 18:20:05 +00:00

417 lines
22 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.
/**
* Maintenance reviews (#249 System S7 Task 19): the concept's "Review maintenance plan" (`maintenancePlan`, app.js
* 953–958) and "Review duplicate image files" (`duplicates`, 1457–1461) on the S6 backend.
*
* - D100 plan dialog: Review plan reads `POST /prompt_manager/maintenance/preview` (read-only) and shows the server's
* plan in the shared review (`openReviewDialog`): the operation's `h3`, the concept's `dl.meta-grid` Scope /
* Protected / Backup / Media on disk with the plan's own values, a `details` list of the records it names, the
* `p.small` line saying until when the review holds, the D55 backup box ("Back up now") and Cancel / "Apply reviewed
* operation" (the concept's "Simulate reviewed operation"). Cancel writes nothing. A plan with nothing to do (a scope
* of 0) shows its rows, "There is nothing to apply; nothing was changed." and only Close.
* - Apply sends the plan's token (`POST /maintenance/apply`): the dialog closes, the job is followed in the Activity
* store, which toasts that it started, and its view opens. A refused apply says why: `busy` names the running job
* (D57), `plan-used` links the job that applied it, an expired or replaced review is reviewed again at once, and an
* apply whose answer was lost says "Outcome not confirmed" and points to Activity (the job may be running).
* - D101 duplicates dialog: a finished duplicate search's groups, ten at a time (`GET /maintenance/duplicates/<job>`),
* one `.compare-grid` per group with a `.compare-item` per copy (its preview and a Keep radio; the oldest copy is
* kept unless another is chosen). Each choice names its copy by `{group_id, root_index, path}`, so equal paths in two
* sources are never confused. The `p.small` says what removing a copy does, History keeping the removed paths in its
* run records. "Review duplicate cleanup" reviews the groups shown so far as a `duplicate_files` plan.
*
* Every request goes through the page's api client; every listener sits on the section's `scope`.
*/
import { createButton } from '../shared/toolkit/core.js';
import { closeWorkspaceDialog, openContentDialog, openReviewDialog } from './workspace-dialogs.mjs';
import { busyToast, downloadDatabaseBackup } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { navigateTo } from './state.mjs';
import { noun } from './plural.mjs';
export const PREVIEW_URL = '/prompt_manager/maintenance/preview';
export const APPLY_URL = '/prompt_manager/maintenance/apply';
export const GROUPS_URL = '/prompt_manager/maintenance/duplicates';
export const GROUP_PAGE = 10;
export const PLAN_TITLE = 'Review maintenance plan';
export const DUPLICATES_TITLE = 'Review duplicate image files';
export const APPLY_LABEL = 'Apply reviewed operation';
export const NOTHING_TO_APPLY = 'There is nothing to apply; nothing was changed.';
export const REVIEW_FAILED = 'The plan could not be reviewed. Nothing was changed.';
export const APPLY_FAILED = 'The operation could not start. Nothing was changed.';
export const GROUPS_FAILED = 'The duplicate groups could not be read. Nothing was changed.';
export const NOT_CONFIRMED = ['Outcome not confirmed. ', { text: 'Check Activity', href: '#/activity' },
' before applying it again.'];
export const DUPLICATES_NOTE = 'Removing a copy deletes its file from disk; its database links move to the kept copy. ' +
'History keeps the removed paths in its run records. Nothing is deleted until you review and apply the cleanup.';
export const BACKUP_COPY = Object.freeze({ heading: 'Back up the database first (recommended)',
text: 'Downloads a copy of the prompts database before this operation changes any records.' });
/** The concept's operation names, which head the plan dialog (and title the server's job). */
export const OPERATION_TITLES = Object.freeze({
duplicate_prompts: 'Duplicate prompt text',
optimize: 'Optimize database',
unused_components: 'Unused prompt components',
missing_links: 'Missing-file links',
duplicate_files: 'Duplicate image files',
// Reviewed and applied through the API and seen in Activity; Maintenance has no row for it (the concept has none).
workflow_storage: 'Store each workflow once',
// S12: Diagnostics & logs › Log files & retention › Review log cleanup (the concept's `maintenance-plan` "rotated logs").
rotated_logs: 'Rotated log copies',
});
/** S12: plans whose details are files, not records ("Listed files"). */
const FILE_PLANS = new Set(['rotated_logs']);
/** S12: a plan that changes no library record (`backup.policy: "none"`): no backup is taken or offered. */
export const NO_BACKUP_TEXT = 'Not needed: no library record changes';
const noBackup = plan => plan?.backup?.policy === 'none';
// The review is gone or no longer matches the database: review it again.
const REVIEW_AGAIN = new Set(['plan-expired', 'storage-changed', 'plan-changed']);
// Refusals whose server message is the whole truth.
const SERVER_SAYS = new Set(['insufficient-space', 'nothing-to-do', 'too-large', 'invalid-request', 'not-found',
'job-running', 'no-groups', 'apply-failed', 'sources-changed', 'lora-disabled', 'lora-path']);
const number = value => value.toLocaleString('en-US');
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The Backup value: what the apply will do before it changes anything. */
export function backupText(backup) {
if (backup?.policy === 'none') return NO_BACKUP_TEXT;
if (backup?.policy === 'off') return 'No safety backup (the policy is off)';
const where = backup?.destination_label ? ` (${backup.destination_label})` : '';
if (backup?.policy === 'required') return `Always created and verified first${where}`;
return `Create and verify before changes${where}`;
}
/** The Protected value: the server's reasons, or that nothing is held back. */
export function protectedText(held) {
const reasons = Array.isArray(held?.reasons) ? held.reasons.filter(reason => typeof reason === 'string' && reason) : [];
if (reasons.length) return reasons.join(' · ');
const count = Number.isSafeInteger(held?.count) ? held.count : 0;
return count ? `${number(count)} ${noun(count, 'record')} kept` : 'Nothing is protected';
}
/** D100: the concept's four rows with the plan's values. */
export function planGrid(plan) {
return [
['Scope', plan.scope?.label || `${number(plan.scope?.count ?? 0)} ${noun(plan.scope?.count ?? 0, 'record')}`],
['Protected', protectedText(plan.protected)],
['Backup', backupText(plan.backup)],
['Media on disk', plan.media?.label || 'No files are removed'],
];
}
const megabytes = bytes => `${(bytes / 2 ** 20).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} MB`;
/** D100: the `p.small` line: until when the review holds, and a disk without room for the apply's writes. */
export function planNote(plan, { now = Date.now() } = {}) {
const parts = [];
const expires = new Date(plan.expires_at);
if (!Number.isNaN(expires.getTime())) {
const minutes = Math.max(1, Math.round((expires.getTime() - now) / 60000));
parts.push(`Nothing changes until you apply. This review holds for ${number(minutes)} ${noun(minutes, 'minute')}.`);
} else {
parts.push('Nothing changes until you apply.');
}
const disk = plan.disk;
if (disk && disk.sufficient === false && Number.isFinite(disk.required_bytes) && Number.isFinite(disk.free_bytes)) {
parts.push(`Not enough free disk space: about ${megabytes(disk.required_bytes)} is needed and ${megabytes(disk.free_bytes)} is free.`);
}
return parts.join(' ');
}
/** Whether the plan changes anything: a scope of 0 records has nothing to apply (an unknown count is not assumed empty). */
export function hasWork(plan) {
return !(Number.isSafeInteger(plan?.scope?.count) && plan.scope.count === 0);
}
/** The records a plan names (at most 50), as `label · detail` lines. */
export function detailLines(plan) {
return (Array.isArray(plan.details) ? plan.details : [])
.filter(row => row && typeof row.label === 'string')
.map(row => (row.detail ? `${row.label} · ${row.detail}` : row.label));
}
/**
* The toast for a refused or failed apply; `review` when the plan must be reviewed again, `lost` when the outcome is
* not known. Only our server's own refusal is definitive (a known code, or a 4xx whose body says success:false): a
* dropped connection, a proxy's 5xx or a 2xx whose body could not be read may follow a started job.
*/
export function applyRefusal(error) {
const status = error?.status;
const known = error?.code && (error.code === 'busy' || error.code === 'plan-used' || REVIEW_AGAIN.has(error.code)
|| SERVER_SAYS.has(error.code));
const refused = Number.isInteger(status) && status >= 400 && status < 500 && error.refused === true;
if (!known && !refused) return { toast: NOT_CONFIRMED, lost: true };
if (error.code === 'busy') return { toast: busyToast(error.running) };
if (error.code === 'plan-used') {
const job = error.job;
if (!job) return { toast: 'This plan was already applied.' };
return { toast: ['This plan was already applied: ', { text: job.title ?? 'its job', href: `#/activity/${job.id}` }, '.'] };
}
if (REVIEW_AGAIN.has(error.code)) return { toast: error.message, review: true };
if (SERVER_SAYS.has(error.code) && error.message) return { toast: error.message };
return { toast: APPLY_FAILED };
}
/** The toast for a review that could not be read. */
export function reviewRefusal(error) {
if ((REVIEW_AGAIN.has(error?.code) || SERVER_SAYS.has(error?.code)) && error.message) return error.message;
return REVIEW_FAILED;
}
/** Hold `trigger` aria-disabled with `busyText` while `work()` runs; a second press is ignored. */
export async function holding(trigger, busyText, work) {
if (trigger?.getAttribute('aria-disabled') === 'true') return;
const text = trigger?.textContent;
trigger?.setAttribute('aria-disabled', 'true');
if (trigger && busyText) trigger.textContent = busyText;
try {
await work();
} finally {
trigger?.removeAttribute('aria-disabled');
if (trigger && busyText) trigger.textContent = text;
}
}
/**
* Apply a reviewed plan's `token` (at `url`: maintenance's apply, the LoRA import's (S10) or the restore's (S9)); the
* job opens in Activity. `reviewAgain()` reviews the operation once more when the server says the review expired, the
* database was replaced or (the LoRA import) its files changed. Resolves with the outcome, which maintenance and the
* LoRA import ignore and the restore acts on (S9 I1): `{job}` once started, `{refused: error}` when the server or the
* network refused it, `{lost: true}` when the answer could not be read (the job may be running).
*/
export async function applyPlan({ api, store, scope, token, reviewAgain, notify = showWorkspaceToast, navigate = navigateTo,
url = APPLY_URL }) {
let result;
try {
result = await api.post(url, { token });
} catch (error) {
if (scope.signal.aborted) return { refused: error };
const refusal = applyRefusal(error);
notify(refusal.toast);
if (refusal.lost) store.load();
if (refusal.review) reviewAgain?.();
return refusal.lost ? { lost: true } : { refused: error };
}
let job;
try {
// The page's store follows the started job even when its section was left meanwhile (S9: a restore that ends
// must still reload the views), but only a section still shown toasts and opens it.
job = store.track(result?.job);
} catch {
if (!scope.signal.aborted) notify(NOT_CONFIRMED);
store.load();
return { lost: true };
}
if (!scope.signal.aborted) {
notify(`${job.title} started.`);
navigate(`#/activity/${job.id}`);
}
return { job };
}
/** The details list's summary: "Listed records (N)", or "Listed files (N)" for a plan of files (S12); "(N of M)" when cut. */
export function detailsSummary(plan, shown) {
const what = FILE_PLANS.has(plan?.operation) ? 'Listed files' : 'Listed records';
const count = plan?.scope?.count ?? shown;
return shown < count ? `${what} (${number(shown)} of ${number(count)})` : `${what} (${number(shown)})`;
}
/** D55's backup box for the review, or null for a plan that changes no library record (S12, `policy: "none"`). */
export function backupBox(plan, onBackup) {
return noBackup(plan) ? null : { copy: BACKUP_COPY, onBackup };
}
/** Show the reviewed `plan` for `operation`; Apply applies exactly this token. */
export function showPlan(plan, { api, store, scope, trigger = null, reviewAgain, notify = showWorkspaceToast,
navigate = navigateTo, backup = () => downloadDatabaseBackup() }) {
const options = [];
const lines = detailLines(plan);
if (lines.length) {
const list = el('details');
list.append(el('summary', detailsSummary(plan, lines.length)));
const body = el('pre', lines.join('\n'));
body.tabIndex = 0;
list.append(body);
options.push(list);
}
const heading = OPERATION_TITLES[plan.operation] ?? plan.operation;
if (!hasWork(plan)) {
// Nothing to apply: the review's own rows and a Close, never an Apply that would start an empty job.
const grid = el('dl', undefined, 'meta-grid');
for (const [term, value] of planGrid(plan)) {
const entry = el('div');
entry.append(el('dt', term), el('dd', value));
grid.append(entry);
}
const close = createButton('Close');
close.addEventListener('click', () => closeWorkspaceDialog());
const actions = el('div', undefined, 'dialog-actions');
actions.append(close);
openContentDialog({ scope, trigger, title: PLAN_TITLE,
nodes: [el('h3', heading), grid, ...options, el('p', NOTHING_TO_APPLY, 'small'), actions] });
return;
}
openReviewDialog({ scope, trigger, title: PLAN_TITLE, heading,
grid: planGrid(plan), options, sentence: planNote(plan), backup: backupBox(plan, backup),
cancelLabel: 'Cancel', primary: { label: APPLY_LABEL },
onStart: () => applyPlan({ api, store, scope, token: plan.token, reviewAgain, notify, navigate }) });
}
/**
* Review `operation` (`POST /maintenance/preview`) and show its plan. `options` are the preview's (duplicate_files:
* `{job_id, keep}`). `hold` (default `trigger`, which gets focus back) is the pressed button: it reads "Reviewing…"
* and ignores presses until the review answered, so a double press sends one preview. Resolves once the plan is shown
* or the refusal toasted.
*/
export async function reviewPlan({ api, store, scope, operation, options = {}, trigger = null, hold = trigger,
notify = showWorkspaceToast, navigate = navigateTo }) {
const again = () => reviewPlan({ api, store, scope, operation, options, trigger, notify, navigate });
await holding(hold, 'Reviewing…', async () => {
let plan;
try {
plan = await api.post(PREVIEW_URL, { operation, options }, { signal: scope.signal });
} catch (error) {
if (!scope.signal.aborted) notify(reviewRefusal(error));
return;
}
if (scope.signal.aborted) return;
showPlan(plan, { api, store, scope, trigger, notify, navigate, reviewAgain: again });
});
}
// ── duplicate image files ───────────────────────────────────────────────────
/** The keep entries for the chosen copies: `{group_id, root_index, path}`, never a bare path (equal paths in two sources). */
export function keepEntries(chosen) {
return [...chosen].map(([groupId, file]) => ({ group_id: groupId, root_index: file.root_index, path: file.path }));
}
/** "Groups 1–10 of 24". */
export function pageText(offset, shown, total) {
if (!total) return 'No duplicate files were found.';
return `${noun(total, 'Group')} ${number(offset + 1)}–${number(offset + shown)} of ${number(total)}`;
}
const kilobytes = bytes => (bytes >= 2 ** 20 ? megabytes(bytes) : `${number(Math.max(1, Math.round(bytes / 1024)))} KB`);
/** A group's line: "3 identical files · 120 KB each". */
export function groupText(group) {
const count = Number.isSafeInteger(group.count) ? group.count : group.files.length;
return `${number(count)} identical ${noun(count, 'file')} · ${kilobytes(group.size ?? 0)} each`;
}
/** A copy's Keep label: its source and path; a file gone since the search says so. */
export function copyLabel(file) {
const where = file.root ? `${file.root}/${file.path}` : file.path;
return file.missing ? `Keep ${where} (no longer on disk)` : `Keep ${where}`;
}
function preview(file) {
const url = file.thumbnail_url ?? (file.media_type === 'video' ? null : file.url);
if (!url) return el('div', file.media_type === 'video' ? 'Video' : 'No preview', 'empty-state');
const image = el('img', undefined, 'sample-fluid');
image.src = window.ApiClient.resolveUrl(url);
image.alt = file.filename ?? file.path;
image.loading = 'lazy';
return image;
}
function copyItem(group, file, index, chosen, scope) {
const item = el('div', undefined, 'compare-item');
const label = el('label');
const radio = el('input');
radio.type = 'radio';
radio.name = `keep-${group.group_id}`;
radio.value = String(index);
radio.disabled = file.missing === true;
const current = chosen.get(group.group_id);
radio.checked = current === file;
scope.listen(radio, 'change', () => { if (radio.checked) chosen.set(group.group_id, file); });
label.append(radio, document.createTextNode(` ${copyLabel(file)}`));
item.append(preview(file), label);
return item;
}
/**
* Open "Review duplicate image files" for the finished search `jobId`, at its first page. Choices are kept across pages;
* the groups shown so far go into the cleanup review.
*/
export async function openDuplicatesReview({ api, store, scope, jobId, trigger = null, notify = showWorkspaceToast,
navigate = navigateTo }) {
const chosen = new Map();
const status = el('p', '', 'small');
status.setAttribute('role', 'status');
const groupsNode = el('div', undefined, 'duplicate-groups');
const previous = createButton('Previous groups');
const next = createButton('Next groups');
const pager = el('div', undefined, 'row-actions');
pager.append(previous, next);
const cancel = createButton('Cancel');
// The concept's `duplicates` dialog: a plain button; the plan it opens has the primary.
const review = createButton('Review duplicate cleanup');
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, review);
let offset = 0;
let total = 0;
async function load(at) {
const query = new URLSearchParams({ offset: String(at), limit: String(GROUP_PAGE) });
const page = await api.get(`${GROUPS_URL}/${encodeURIComponent(jobId)}?${query}`, { signal: scope.signal, cache: 'no-store' });
const groups = Array.isArray(page?.groups) ? page.groups : [];
offset = at;
total = Number.isSafeInteger(page?.total) ? page.total : groups.length;
const nodes = [];
for (const group of groups) {
const files = Array.isArray(group.files) ? group.files : [];
if (!chosen.has(group.group_id)) {
const keep = files.find(file => !file.missing);
if (keep) chosen.set(group.group_id, keep);
} else {
// A page read again: the same copy, by its identity, stays chosen.
const was = chosen.get(group.group_id);
const same = files.find(file => file.root_index === was.root_index && file.path === was.path);
if (same) chosen.set(group.group_id, same);
}
const grid = el('div', undefined, 'compare-grid');
grid.append(...files.map((file, index) => copyItem(group, file, index, chosen, scope)));
nodes.push(el('p', groupText({ ...group, files }), 'small'), grid);
}
groupsNode.replaceChildren(...nodes);
status.textContent = pageText(offset, groups.length, total);
previous.disabled = offset === 0;
next.disabled = offset + groups.length >= total;
review.disabled = chosen.size === 0;
}
try {
await load(0);
} catch (error) {
if (!scope.signal.aborted) notify(reviewRefusal(error) === REVIEW_FAILED ? GROUPS_FAILED : error.message);
return;
}
if (scope.signal.aborted) return;
const turn = async at => {
try {
await load(at);
} catch (error) {
if (!scope.signal.aborted) notify(GROUPS_FAILED);
}
};
scope.listen(previous, 'click', () => turn(Math.max(0, offset - GROUP_PAGE)));
scope.listen(next, 'click', () => turn(offset + GROUP_PAGE));
scope.listen(cancel, 'click', () => closeWorkspaceDialog());
scope.listen(review, 'click', () => {
if (review.disabled) return;
reviewPlan({ api, store, scope, operation: 'duplicate_files', trigger, hold: review,
options: { job_id: jobId, keep: keepEntries(chosen) }, notify, navigate });
});
const nodes = [status, groupsNode];
if (total > GROUP_PAGE) nodes.push(pager);
nodes.push(el('p', DUPLICATES_NOTE, 'small'), actions);
openContentDialog({ scope, title: DUPLICATES_TITLE, trigger, nodes });
}