Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/system-backups.mjs
T
Vito Sansevero 519f17bf63 feat(system): back up, restore and export natively
System > Backup & export replaces its hand-off with the concept's section on the
S8 backend: Back up downloads the verified copy and the status line shows only
what GET /backup/status reports (D103); Review restore stages a chosen file and
shows the server's review in the shared review dialog, Cancel discards the stage
and Restore runs the Database restore job, after which the open views reload
(D105); Export JSON plus the components and saved-pairs exports (D103); Import /
inspect opens the shared Import & inspect dialog; the safety-backup policy saves
through the Save row and scheduled backups offer Manual only (D104). Overview's
Backup status row reads the same status.

Refs #326
2026-10-25 11:11:42 -07:00

416 lines
21 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.
/**
* System › Backup & export (#249 System S9 Task 24): the concept's `renderSystem` "backups" body (app.js 691–693), its
* `restore` dialog ("Restore preview", a1472–1476) and its `import` dialog (a1483–1487) on the S8 backend.
*
* - "Database backup": D103 "Back up" (the concept's "Simulate backup") downloads the verified copy of
* `GET /prompt_manager/backup` through the api client (`downloadDatabaseBackup`, the same download as every backup
* box), then reads the status again. The `p.small` under the actions is `GET /prompt_manager/backup/status` only:
* this session's last download (verified or not) and the newest safety backup on disk, else "No backup recorded."
* Overview's "Backup status" row says the same (`backupStatusText`).
* - "Review restore…" picks a `.db` file and stages it (`POST /prompt_manager/restore/preview`); D105: "Restore
* preview" shows the server's answer in the shared review (`openReviewDialog`): the concept's Snapshot / Integrity /
* Migration / Safety backup rows with real values, plus what the backup holds and what the library holds now, the
* hold line, the D55 backup box and Cancel / "Restore" (`danger`). Leaving it any way but Restore discards the stage
* (`DELETE /prompt_manager/restore/preview/<token>`). Restore starts the "Database restore" Activity job (exclusive,
* not cancellable) and opens its view; a finished restore reloads the open views (`onRestoreFinished`, index.mjs).
* A file the server refuses toasts its validator's reason; nothing is staged.
* - "Portable export": D103 "Export JSON" downloads the Library document (`/library/export`); the secondary row
* downloads the legacy components list (`/export`) and the saved pairs (`/pairs/export`). "Import / inspect file"
* opens the shared "Import & inspect" (D15, D55): Inspect a local image, Rescan and Scan for metadata.
* - D104 "Recovery policy" (the concept's "Proposed" chip kept, Q4): "Create a safety backup before maintenance" is
* the server's `safety_backup` policy, drafted behind the Save row and saved with the revision; "Scheduled database
* backups" offers Manual only, Daily and Weekly disabled with the reason; the warning note is truthful.
*
* Every request goes through the api client (or `fetchUrl` for a download or an upload); every listener sits on the
* section's `scope`.
*/
import { createButton } from '../shared/toolkit/core.js';
import { navigateTo } from './state.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { settingRow } from './system-controls.mjs';
import { busyToast, downloadDatabaseBackup, openMetadataScan } from './activity-scan.mjs';
import { applyPlan } from './maintenance-review.mjs';
import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openReviewDialog, rescanFinished } from './workspace-dialogs.mjs';
import { noun } from './plural.mjs';
export const STATUS_URL = '/prompt_manager/backup/status';
export const PREVIEW_URL = '/prompt_manager/restore/preview';
export const APPLY_URL = '/prompt_manager/restore/apply';
const SETTINGS_URL = '/prompt_manager/settings';
const RESCAN_URL = '/prompt_manager/gallery/rescan';
export const RESTORE_KIND = 'restore';
export const RESTORE_TITLE = 'Restore preview';
export const RESTORE_LABEL = 'Restore';
export const STATUS_LOADING = 'Checking backups…';
export const STATUS_FAILED = 'The backup status could not be read.';
export const NO_BACKUP = 'No backup recorded.';
export const REVIEW_FAILED = 'The backup could not be reviewed. Nothing was changed.';
export const EXPORT_FAILED = 'The export could not be downloaded. Nothing was saved to this device.';
export const SOURCES_FAILED = 'The output folders could not be read. Try again.';
export const HOST_UNAVAILABLE = 'Server settings could not be read.';
export const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
export const WARNING_NOTE = 'Restores are staged, migrated and validated before replacing live data.';
export const RESTORE_BACKUP_COPY = Object.freeze({ heading: 'Back up the database first (recommended)',
text: 'Downloads a copy of the prompts database before the restore replaces it.' });
/** D103: the concept's Export plus the two secondary exports: `[label, url, fallback file name]`. */
export const EXPORTS = Object.freeze([
['Export JSON', '/prompt_manager/library/export', 'prompt-manager-library-v1.json'],
['Export components JSON', '/prompt_manager/export', 'prompt-manager-components.json'],
['Export saved pairs JSON', '/prompt_manager/pairs/export', 'prompt-pairs.json'],
]);
const number = value => value.toLocaleString('en-US');
const WHEN = Object.freeze({ dateStyle: 'medium', timeStyle: 'short' });
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** A server time as the reader's local date and time, or null when it is not one. */
export function whenText(value) {
const when = new Date(value);
return typeof value === 'string' && !Number.isNaN(when.getTime()) ? when.toLocaleString(undefined, WHEN) : null;
}
/**
* The status line (and Overview's Backup status) from `GET /backup/status`: undefined while it is read, null when it
* failed. Only what the server reports is named: this session's last download and the newest safety backup on disk.
*/
export function backupStatusText(status) {
if (status === undefined) return STATUS_LOADING;
if (status === null || typeof status !== 'object') return STATUS_FAILED;
const parts = [];
const download = status.last_download;
const downloaded = whenText(download?.at);
if (downloaded) parts.push(`Downloaded backup · ${downloaded} · ${download.verified === true ? 'verified' : 'not verified'}.`);
const safety = Array.isArray(status.safety_backups) ? status.safety_backups : [];
const newest = whenText(safety[0]?.modified_at);
if (newest) parts.push(`Safety backup · ${newest} · ${number(safety.length)} on disk.`);
return parts.join(' ') || NO_BACKUP;
}
/** "1,234 prompts · 56 image links · 3 saved pairs · 10 runs" for a restore summary (backup or live library). */
export function countsText(counts) {
const parts = [['prompts', 'prompt'], ['image_links', 'image link'], ['pairs', 'saved pair'], ['runs', 'run']]
.filter(([key]) => Number.isSafeInteger(counts?.[key]))
.map(([key, word]) => `${number(counts[key])} ${noun(counts[key], word)}`);
return parts.join(' · ') || 'Not reported';
}
/** The Integrity value: verified, with what the stage repaired or could not (S8's `documents_line`). */
export function integrityText(preview) {
const verified = preview?.integrity === 'ok' ? 'Verified' : 'Not verified';
const documents = preview?.documents ?? {};
const repaired = Number.isSafeInteger(documents.repaired) ? documents.repaired : 0;
const cleared = Number.isSafeInteger(documents.cleared) ? documents.cleared : 0;
const parts = [verified];
if (repaired) parts.push(`repaired ${number(repaired)} damaged workflow ${noun(repaired, 'document')}`);
if (cleared) {
const images = Number.isSafeInteger(documents.images) ? documents.images : 0;
parts.push(`could not repair ${number(cleared)} workflow ${noun(cleared, 'document')}: ${number(images)} ` +
`${noun(images, 'image')} ${images === 1 ? 'shows' : 'show'} no workflow`);
}
return parts.join(' · ');
}
/** The Snapshot value: the chosen file's name, size and date. */
export function snapshotText(file) {
const size = Number.isFinite(file?.size) ? ` · ${(file.size / 2 ** 20).toLocaleString('en-US', { minimumFractionDigits: 1,
maximumFractionDigits: 1 })} MB` : '';
const when = Number.isFinite(file?.lastModified) ? whenText(new Date(file.lastModified).toISOString()) : null;
return `${file?.name ?? 'Backup file'}${size}${when ? ` · ${when}` : ''}`;
}
/** D105: the concept's four rows with the review's values, then the two the decision needs. */
export function restoreGrid(preview, file) {
const destination = preview?.safety_backup?.destination_label;
return [
['Snapshot', snapshotText(file)],
['Integrity', integrityText(preview)],
['Migration', preview?.schema?.migrated === true ? 'Upgraded to the current schema' : 'Current schema'],
['Safety backup', `Created before replacing${destination ? ` (${destination})` : ''}`],
['In the backup', countsText(preview?.summary)],
['In the library now', countsText(preview?.live)],
];
}
/** D105: the `p.small` line: what Restore does and until when the review holds. */
export function restoreNote(preview, { now = Date.now() } = {}) {
const parts = ['Restore replaces the whole library with this backup and runs alone in Activity.'];
const expires = new Date(preview?.expires_at);
if (!Number.isNaN(expires.getTime())) {
const minutes = Math.max(1, Math.round((expires.getTime() - now) / 60000));
parts.push(`Nothing changes until you restore. This review holds for ${number(minutes)} ${noun(minutes, 'minute')}.`);
} else {
parts.push('Nothing changes until you restore.');
}
return parts.join(' ');
}
/** The toast for a review the server refused (its reason, a running job) or that could not be read. */
export function reviewRefusal(error) {
if (error?.code === 'busy') return busyToast(error.running);
if (error?.refused === true && typeof error.message === 'string' && error.message) {
if (error.code === 'invalid-backup') return `This file can't be restored: ${error.message.replace(/\.?$/, '.')}`;
return error.message;
}
return REVIEW_FAILED;
}
/** Stage `file` for review; the server's answer, or an ApiError naming its refusal. */
export async function uploadRestore(file, { fetchUrl = window.ApiClient.fetchUrl, ApiError = window.ApiClient.ApiError,
signal } = {}) {
const form = new FormData();
form.append('database_file', file, file.name);
const response = await fetchUrl(PREVIEW_URL, { method: 'POST', body: form, cache: 'no-store', credentials: 'same-origin',
signal });
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
const message = data && typeof data === 'object' ? data.error : undefined;
if (!response.ok || !data || data.success === false || typeof data.token !== 'string') {
throw new ApiError(response.status, message || `HTTP ${response.status}`, data?.code, null, data);
}
return data;
}
/** Discard a staged review; a failure only leaves it to expire on the server. */
export function discardRestore(token, { fetchUrl = window.ApiClient.fetchUrl } = {}) {
return fetchUrl(`${PREVIEW_URL}/${encodeURIComponent(token)}`, { method: 'DELETE', keepalive: true, credentials: 'same-origin' })
.then(() => {}, () => {});
}
/** Hold `trigger` aria-disabled with `busyText` while `work()` runs; a second press is ignored. */
async function holding(trigger, busyText, work) {
if (trigger?.getAttribute('aria-disabled') === 'true') return;
const text = trigger?.textContent;
trigger?.setAttribute('aria-disabled', 'true');
if (trigger) trigger.textContent = busyText;
try {
await work();
} finally {
trigger?.removeAttribute('aria-disabled');
if (trigger) trigger.textContent = text;
}
}
/**
* Stage `file`, then show "Restore preview"; Restore applies exactly that token. A review the server asks to redo (it
* expired, or the library changed) stages the same file again.
*/
export async function reviewRestore({ api, store, scope, file, trigger = null, hold = trigger, onBackup,
notify = showWorkspaceToast, navigate = navigateTo, fetchUrl = window.ApiClient.fetchUrl }) {
await holding(hold, 'Reviewing…', async () => {
let preview;
try {
preview = await uploadRestore(file, { fetchUrl, signal: scope.signal });
} catch (error) {
if (!scope.signal.aborted) notify(reviewRefusal(error));
return;
}
if (scope.signal.aborted) {
discardRestore(preview.token, { fetchUrl });
return;
}
const again = () => reviewRestore({ api, store, scope, file, trigger, onBackup, notify, navigate, fetchUrl });
openReviewDialog({ scope, trigger, title: RESTORE_TITLE, grid: restoreGrid(preview, file),
sentence: restoreNote(preview), backup: { copy: RESTORE_BACKUP_COPY, onBackup },
cancelLabel: 'Cancel', primary: { label: RESTORE_LABEL, danger: true },
onDismiss: () => discardRestore(preview.token, { fetchUrl }),
onStart: () => applyPlan({ api, store, scope, token: preview.token, reviewAgain: again, notify, navigate,
url: APPLY_URL }) });
});
}
/** The file name a download's Content-Disposition names, or `fallback`. */
function filenameOf(response, fallback) {
const match = /filename="?([^";]+)"?/i.exec(response.headers.get('Content-Disposition') || '');
return match ? match[1] : fallback;
}
/** Download one export through `fetchUrl`; toasts a failure, and nothing is saved then. */
export async function downloadExport([, url, fallback], { fetchUrl = window.ApiClient.fetchUrl, notify = showWorkspaceToast,
signal } = {}) {
try {
const response = await fetchUrl(url, { cache: 'no-store', credentials: 'same-origin', signal });
if (!response.ok) throw new Error(`Export failed with ${response.status}`);
const blob = await response.blob();
if (signal?.aborted) return false;
const href = URL.createObjectURL(blob);
const link = el('a');
link.href = href;
link.download = filenameOf(response, fallback);
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(href), 1000);
return true;
} catch {
if (!signal?.aborted) notify(EXPORT_FAILED);
return false;
}
}
/**
* Run `onFinished()` once each time a restore this page saw running ends finished (the library was replaced). A
* failed restore changed nothing. Returns the unsubscribe.
*/
export function onRestoreFinished(activity, onFinished) {
const running = new Set();
return activity.subscribe(() => {
let finished = false;
for (const job of activity.jobs()) {
if (job.kind !== RESTORE_KIND) continue;
if (job.state === 'running') running.add(job.id);
else if (running.delete(job.id) && job.state === 'finished') finished = true;
}
if (finished) onFinished();
});
}
function surface(heading, ...nodes) {
const section = el('section', undefined, 'surface');
section.append(heading, ...nodes);
return section;
}
/** The Backup & export section's mount (`mountSystem`'s `sections.backups`). */
export function mountBackups(container, { api, activity, scope, drafts, notify = showWorkspaceToast, navigate = navigateTo,
fetchUrl = window.ApiClient.fetchUrl, latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
const statusRead = latestOnly({ lifetime: scope.signal });
const status = el('p', STATUS_LOADING, 'small');
status.id = 'backup-status';
status.setAttribute('role', 'status');
async function loadStatus() {
let answer;
try {
answer = await statusRead(signal => api.get(STATUS_URL, { signal, cache: 'no-store' }));
} catch {
answer = null;
}
if (answer === STALE || scope.signal.aborted) return;
status.textContent = backupStatusText(answer);
}
const backupNow = async () => {
const done = await downloadDatabaseBackup({ fetchUrl, notify });
if (done && !scope.signal.aborted) loadStatus();
return done;
};
// Database backup: Back up, Review restore… and the status line.
const backup = createButton('Back up', 'ca-primary');
const restore = createButton('Review restore…', 'danger');
const picker = el('input');
picker.type = 'file';
picker.accept = '.db';
picker.hidden = true;
picker.id = 'restore-file';
picker.setAttribute('aria-label', 'Backup file to restore');
// Outside the concept's surface, for as long as the section is shown.
document.body.append(picker);
scope.cleanup(() => picker.remove());
scope.listen(backup, 'click', () => holding(backup, 'Backing up…', backupNow));
scope.listen(restore, 'click', () => { if (restore.getAttribute('aria-disabled') !== 'true') picker.click(); });
scope.listen(picker, 'change', () => {
const [file] = picker.files ?? [];
picker.value = '';
if (!file) return;
reviewRestore({ api, store: activity, scope, file, trigger: restore, onBackup: backupNow, notify, navigate, fetchUrl });
});
const backupActions = el('div', undefined, 'row-actions spaced');
backupActions.append(backup, restore);
const database = surface(el('h2', 'Database backup'),
el('p', 'A complete recovery snapshot, including prompt pairs, metadata and image links. Media files are separate.'),
backupActions, status);
// Portable export: Export JSON and Import / inspect file, then the two secondary exports (D103).
const exportButtons = EXPORTS.map(entry => {
const button = createButton(entry[0]);
scope.listen(button, 'click', () => holding(button, 'Exporting…',
() => downloadExport(entry, { fetchUrl, notify, signal: scope.signal })));
return button;
});
const importButton = createButton('Import / inspect file');
let rescanning = false;
let importDialog = null;
async function rescan() {
if (rescanning || scope.signal.aborted) return;
rescanning = true;
notify(RESCAN_STARTED);
try {
const result = await api.post(RESCAN_URL, {}, { signal: scope.signal });
if (!scope.signal.aborted) notify(rescanFinished(result));
} catch {
if (!scope.signal.aborted) notify(RESCAN_FAILED);
} finally {
rescanning = false;
importDialog?.setRescanBusy(false);
}
}
async function metadataScan(trigger) {
let settings;
try {
settings = await api.get(SETTINGS_URL, { signal: scope.signal, cache: 'no-store' });
} catch {
if (!scope.signal.aborted) notify(SOURCES_FAILED);
return;
}
if (scope.signal.aborted) return;
const sources = Array.isArray(settings?.sources?.effective) ? settings.sources.effective.length : 0;
openMetadataScan({ scope, store: activity, sources, trigger });
}
scope.listen(importButton, 'click', () => {
importDialog = openImportDialog({ scope, trigger: importButton, rescanBusy: rescanning,
onInspect: () => navigate('#/inspect'), onRescan: rescan, onMetadataScan: metadataScan });
});
const exportActions = el('div', undefined, 'row-actions spaced');
exportActions.append(exportButtons[0], importButton);
const moreExports = el('div', undefined, 'row-actions spaced');
moreExports.append(...exportButtons.slice(1));
const portable = surface(el('h2', 'Portable export'),
el('p', 'Export reusable recipes and tags as versioned JSON. This is distinct from a complete database backup.'),
exportActions, moreExports);
const columns = el('div', undefined, 'two-col');
columns.append(database, portable);
// Recovery policy (D104): the real safety-backup policy and the schedule the server does not have yet.
const heading = el('h2', 'Recovery policy ');
heading.append(el('span', 'Proposed', 'chip blue'));
const safety = settingRow({ id: 'backup-before', label: 'Create a safety backup before maintenance',
description: 'Preview a verified backup before applying operations that change records.',
control: { type: 'toggle' }, scope, onChange: value => drafts.set('host', 'safety_backup', value) });
const safetyInput = safety.querySelector('#backup-before');
safetyInput.dataset.key = 'safety_backup';
const schedule = settingRow({ id: 'backup-schedule', label: 'Scheduled database backups', description: SCHEDULE_REASON,
control: { type: 'select', options: ['Manual only', 'Daily', 'Weekly'], value: 'Manual only' } });
for (const option of schedule.querySelectorAll('option')) option.disabled = option.value !== 'Manual only';
const policy = surface(heading, safety, schedule, el('p', WARNING_NOTE, 'warning-note'));
let hostNote = null;
function paintPolicy() {
const value = drafts.get('host', 'safety_backup');
safetyInput.disabled = typeof value !== 'boolean';
if (typeof value === 'boolean') safetyInput.checked = value;
const unavailable = drafts.hostState?.() === 'failed' && typeof value !== 'boolean';
if (unavailable && !hostNote) {
hostNote = el('p', HOST_UNAVAILABLE, 'small');
safety.children[0].append(hostNote);
} else if (!unavailable && hostNote) {
hostNote.remove();
hostNote = null;
}
}
scope.cleanup(drafts.subscribe(paintPolicy));
paintPolicy();
loadStatus();
return [columns, policy];
}