Compare commits

...
Author SHA1 Message Date
Vito Sansevero 367db8d7d6 Merge remote-tracking branch 'origin/main' into feat/326-system-s9-backup-ui 2026-10-25 13:03:08 -07:00
Vito Sansevero 447fd4668b test(coverage): raise the system-backups branch floor
Refs #326
2026-10-25 12:48:06 -07:00
Vito Sansevero af2d8e3792 fix(system): keep a refused or handed-back restore review usable
A Restore refused as busy now opens the same review again with Discard /
Restore, and a restore job whose leases did not drain hands its review back
(scope.review_kept) to the page that started it, which reopens it; every
other path that abandons a token after Restore sends its DELETE, so held
reviews can no longer lock restore out. The kept-review line says where the
review is, and a full review store says what to do (I1). applyPlan resolves
with its outcome and its job tracking now has failing-before proof (N5).

Also: the safety-backup toggle says what the policy does (D104, M2); the
restore picker has no type filter so safety backups can be chosen (M3);
"Export all prompts JSON" names what /export holds (N1); small snapshots
show KB (N3); the duplicated holding and save helpers are shared (N4); a
dismissal test covers Cancel, x, Escape, a replacing dialog and leaving (M1).

Refs #326
2026-10-25 12:38:28 -07:00
Vito Sansevero dde8b99b75 Merge remote-tracking branch 'origin/main' into feat/326-system-s9-backup-ui 2026-10-25 11:53:53 -07:00
Vito Sansevero 61d5518831 test(coverage): raise the frontend floors S9 measured
system-backups.mjs gets its measured floor in place of the provisional one;
admin.js, activity-scan.mjs and maintenance-review.mjs branches go up.

Refs #326
2026-10-25 11:34:02 -07:00
Vito Sansevero 9823a11492 docs(testing): Backup & export contract (D103-D105)
Refs #326
2026-10-25 11:23:43 -07:00
Vito Sansevero 6d4616a182 test(system): compare Backup & export with the concept
system-backups-concept.spec compares the section, the Restore preview and the
Import & inspect dialog with the concept in both themes at 1600 and 390 wide,
mapping only D103, D104, D105 (and D15/D55 for the shared Import dialog), and
proves a renamed label, a removed row, a lost danger mark and swapped tokens
are noticed. A review's grid values now wrap anywhere (D105), so a long backup
file name keeps the concept's two equal columns at phone width.

Refs #326
2026-10-25 11:22:46 -07:00
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
26 changed files with 1935 additions and 70 deletions
+99 -4
View File
@@ -2378,7 +2378,8 @@ paints nothing. Until it answers, the section shows "Loading the overview…"; a
shows "The overview could not be loaded. Your saved data has not changed." and Retry,
never zeros. The thumbnail row is a separate read of `/images/thumbnails/status` (a
walk of every source), so the other values never wait for it: "Checking thumbnails…"
until it answers. The health check (`POST /prompt_manager/system/health`) is a `read`
until it answers. The Backup status row is likewise its own read of
`/prompt_manager/backup/status` (S9). The health check (`POST /prompt_manager/system/health`) is a `read`
registry job of kind `health`: one at a time, beside a scan, not cancellable and with
no file outcomes. The page tracks the started job in the Activity store (its event
stream; the store marks it lost if the server stops listing it), never by polling. A press while one runs (another tab) waits
@@ -2399,7 +2400,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
reader can keep it byte-identical (`test_health_check_never_writes`).
Recorded deviations (plan D60–D70, D74, D75, D79–D82 renumbered D92–D102, D106, D107 and D111–D114; D121–D131 assigned):
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–D114; D121–D131 assigned):
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
@@ -2416,7 +2417,10 @@ Recorded deviations (plan D60–D70, D74, D75, D79–D82 renumbered D92–D102,
`needs_review.pending`) with the concept's line; its Review metadata action opens
`#/review` (the concept's `go-review`). A count
that is not an integer reads "Ambiguous image links" / "Not available yet." (Q6).
"Backup status" reads "No retained backup record." until verified backups exist (S8).
"Backup status" says what `GET /prompt_manager/backup/status` reports, in Backup &
export's own status line (D103, S9): "Checking backups…" until its own read answers,
then the session's last download and the newest safety backup, "No backup recorded."
or "The backup status could not be read.".
The thumbnail row is "Checking thumbnails…" until its own read answers, then "No
missing thumbnails", "N missing thumbnails" (the pending count of
`/images/thumbnails/status`, followed by " · M outdated" when previews are in an
@@ -2615,6 +2619,68 @@ Recorded deviations (plan D60–D70, D74, D75, D79–D82 renumbered D92–D102,
is D93's style. `maintenance matches the concept` (both themes and sizes) maps only this
row; `maintenance comparison detects mutations` proves it is the only difference and
catches a removed row, a renamed operation, a lost danger mark and a swapped token.
- **D103** (Backup & export, S9): "Back up" (the concept's "Simulate backup") downloads
the verified copy of `GET /prompt_manager/backup` through the api client (the same
`downloadDatabaseBackup` as every backup box; a copy whose `X-PromptManager-Verify` is
not `ok` toasts "Database backup downloaded, but it did not pass verification:
<reason>"), then reads the status again. The `p.small#backup-status` under the
actions shows only what `GET /prompt_manager/backup/status` reports: "Downloaded
backup · <date, time> · verified." (or "not verified") for this session's last
download and "Safety backup · <date, time> · N on disk." for the newest
`<db>.backup_*`, else "No backup recorded."; "Checking backups…" while read and "The
backup status could not be read." when it failed (never "no backup"). Portable
export's "Export JSON" (the concept's "Export sample JSON") downloads the Library
document (`/library/export`); a second `.row-actions.spaced` adds "Export all prompts
JSON" (`/export`, the legacy admin page's Export of every prompt) and "Export saved pairs JSON"
(`/pairs/export`), plain buttons as the concept's; a failed export toasts "The export
could not be downloaded. Nothing was saved to this device." "Import / inspect file"
opens the shared "Import & inspect" (D15, D55), as Images does.
- **D104** (Backup & export, S9, Q4): Recovery policy keeps the concept's "Proposed" chip.
"Create a safety backup before maintenance" is the server's `safety_backup`
(`maintenance.safety_backup`) policy. Its line says what it does ("Keep a verified copy
of the database on disk before maintenance, cleanups and LoRA imports change records. A
restore always keeps one."); the concept's "Preview a verified backup before applying
operations that change records." describes nothing the server does. It is drafted behind the Save row and saved with
`expected_revision` (a configuration changed elsewhere is the conflict status); it is
disabled with "Server settings could not be read." when the read failed. "Scheduled
database backups" offers Manual only; Daily and Weekly stay listed but disabled
(there is no scheduler), the row's line is "Daily and Weekly are not available yet:
back up with Back up whenever you choose." (the concept's proposal line is
prototype copy) and nothing is drafted for it. The warning note reads "Restores are
staged, migrated and validated before replacing live data."
- **D105** (Backup & export, S9): "Review restore…" picks a file (no type filter: the
safety backups `<db>.backup_<time>` have no `.db` extension; the server validates) and stages it
(`POST /prompt_manager/restore/preview`, multipart through `fetchUrl`; the button reads
"Reviewing…" meanwhile). "Restore preview" is the shared review: no sample note; the
concept's `dl.meta-grid` Snapshot (file name · size · date), Integrity ("Verified" plus
the stage's document repairs), Migration ("Current schema" / "Upgraded to the current
schema"), Safety backup ("Created before replacing (<label>)": the restore always
keeps one; the Snapshot size is whole KB below 1 MB), then "In the backup" and "In the library now" (prompts, image links, saved
pairs, runs); a `p.small` "Restore replaces the whole library with this backup and
runs alone in Activity. Nothing changes until you restore. This review holds for N
minutes."; the D55 backup box ("Downloads a copy of the prompts database before the
restore replaces it."); Cancel / "Restore" (the concept's "Simulate restore", still
`danger`). Leaving it any way but Restore (Cancel, ×, Escape, another dialog, the page
leaving) discards the stage (`DELETE …/preview/<token>`). Restore starts the "Database
restore" Activity job (exclusive, not cancellable, indeterminate; its descriptor ends
with the server's line) and opens its view; a review the server asks to redo (expired,
library changed) stages the same file again. A Restore refused as `busy` (another job
runs; the server keeps the token) opens the same review again with "Discard" / "Restore":
Restore retries, Discard (or leaving) releases it. A job whose leases did not drain
hands its review back (`scope.review_kept`, "…The review was reopened in the page that
started the restore: restore it again there, or discard it."): the page that started it
opens the review again over the page shown now, with Discard / Restore. Every other path
that abandons a token after Restore (a used, expired or replaced review, a lost answer)
sends its DELETE, which changes nothing for a token the server dropped or a job took. A
third review while two are held elsewhere toasts "Two restores are already being
reviewed in another window. Discard one there, or try again when it expires (15 minutes
after it was reviewed)." An upload whose answer is lost because the section was left
mid-response is not discarded by the page: the server's 15-minute expiry removes it. When a restore the page saw running
finishes, the cached Images and Library visits are dropped, the sidebar counts and
saved views are read again and the open route is shown again (the Activity job view
stays). A refused file toasts "This file can't be restored: <validator reason>.". A
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
long file name keeps the concept's two equal columns at phone width.
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
@@ -3353,7 +3419,8 @@ the executor. `POST /prompt_manager/restore/apply {token}` rechecks the live fin
`plan-changed`, stage dropped) and starts "Database restore": kind `restore`, admission
`exclusive` (it runs alone, like Move library and Optimize), not cancellable,
indeterminate. Its runner waits for request leases (when they outlast the wait the job
fails `busy` and the review is kept under its token), rechecks the fingerprint,
fails `busy` and the review is kept under its token, flagged `scope.review_kept` for the
page that started it), rechecks the fingerprint,
publishes, swaps `self.db`, prunes the pool and logs the safety backup by name, any
document repair and the restored counts; a failed page copy keeps the safety backup and
says so. Once the copy replaced the library the restore succeeded: pruning, reopening
@@ -3382,6 +3449,34 @@ removed. `GET
/prompt_manager/backup/status` lists up to ten `<db>.backup_*` files newest first (name,
bytes, modified time; no paths) and this session's last verified download, or null.
System › Backup & export (`system-backups.mjs`, S9) is the concept's "backups" body on
these routes (D103–D105 above); Overview's Backup status row reads the same status. The
admin page's Backup and Restore are unchanged. `tests/browser/system-backups-concept.spec.mjs`
compares the section, the Restore preview and the Import & inspect dialog with the
concept (both themes, 1600×1100 and 390×844), mapping only D103–D105 (D15 and D55 for the
shared Import dialog); unmapped, exactly those paths differ, and `backup & export
comparison detects mutations` renames a label, removes a row, drops a danger mark and
swaps tokens. `tests/browser/workspace-system-backups.spec.mjs` covers Back up and its
revoked object URL, the status line against the server (none, a safety backup, a failed
read), Overview's row, the restore review against the preview (and that it writes
nothing), Cancel and Escape discarding the stage, a restore through Activity that
reloads the counts and the open Library, a busy refusal reopened and retried, two
refusals then a third review, ×, a section and a route leave discarding, a safety-backup
file name, an invalid file, the three exports and a
failed one, Import / inspect (Rescan while running, Scan for metadata, Inspect), the
safety-backup policy with its revision and a conflict, the schedule, a failed settings
read and the legacy routes. The fixture (`workspace_system_fixture.py`) writes a safety
backup (`/__test__/system/safety-backup`), lists the backups and held stages
(`GET /__test__/system/backups`), and its reset drops held reviews, removes the safety
backups and forgets the last download.
```sh
node --test tests/js/workspace-system-backups.test.js tests/js/workspace-system-overview.test.js
npm run --prefix tools/vendor test:workflows -- workspace-system-backups.spec.mjs \
system-backups-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
+6 -1
View File
@@ -90,9 +90,13 @@ REQUESTS_BUSY = (
"Other requests are still reading the library; nothing was changed. "
"Try again in a moment."
)
# S9 I1: the review goes back to the page that started the restore
# (``scope.review_kept``), which opens it again with Restore and Discard; no
# other place can start a kept review, so the line says where it is.
REVIEW_KEPT = (
"Other requests are still reading the library; nothing was changed. "
"The review is kept: start the restore again in a moment."
"The review was reopened in the page that started the restore: restore it "
"again there, or discard it."
)
VERIFY_HEADER = "X-PromptManager-Verify"
SQLITE_HEADER = b"SQLite format 3\x00"
@@ -873,6 +877,7 @@ class BackupRestoreRoutesMixin:
if token is not None and self._restore_plans.give_back(token, held):
keep_stage = True
self._schedule_restore_expiry()
job.scope["review_kept"] = True
return _fail_job(job, "busy", REVIEW_KEPT)
return _fail_job(job, "busy", REQUESTS_BUSY)
job.update(status_line="Checking the library")
@@ -0,0 +1,329 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
// #249 System S9 Task 25: System › Backup & export (app.js renderSystem 691–693), its "Restore preview" (`restore`,
// 1472–1476) and its "Import & inspect" (`import`, 1483–1487) against the immutable concept, on the concept-shaped
// fixture. Each concept measurement is mapped only by the recorded deviations (D103, D104, D105; the Import dialog's
// D15 and D55), each commented where it applies; after that the difference must be empty, and a mutated label, row,
// danger mark or token must be noticed.
test.use({ locale: 'en-US', timezoneId: 'UTC' });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
page.conceptErrors = [];
page.on('pageerror', error => page.conceptErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
expect(page.conceptErrors).toEqual([]);
});
const PREVIEW_URL = '/prompt_manager/restore/preview';
/** Runs in the page: the section's surfaces under `mainSelector`, their text, controls, geometry and tokens. */
function measureBackups(mainSelector) {
const layout = document.querySelector(`${mainSelector} .settings-layout`);
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const button = node => ({ text: text(node), className: node.className,
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') });
const base = layout.getBoundingClientRect();
const box = node => {
const rect = node.getBoundingClientRect();
return { left: Math.round(rect.left - base.left), top: Math.round(rect.top - base.top), width: Math.round(rect.width) };
};
const control = row => {
const select = row.querySelector(':scope > select');
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => [option.textContent, option.disabled]),
selected: select.selectedOptions[0]?.textContent ?? null, disabled: select.disabled };
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
return toggle ? { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
disabled: toggle.disabled, ...css(toggle.parentElement, 'width', 'height') } : null;
};
const columns = layout.querySelector(':scope > .two-col');
const surfaces = [...columns.querySelectorAll(':scope > section.surface')];
const policy = layout.querySelector(':scope > section.surface');
const chip = policy.querySelector(':scope > h2 > .chip');
const row = policy.querySelector('.setting-row');
const status = surfaces[0].querySelector(':scope > p.small');
const warning = policy.querySelector(':scope > .warning-note');
return {
layout: [...layout.children].map(name),
columns: { ...css(columns, 'display', 'gridTemplateColumns', 'columnGap'), box: box(columns) },
surfaces: surfaces.map(surface => ({ tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')),
intro: text(surface.querySelector(':scope > p:not(.small)')), box: box(surface),
actions: [...surface.querySelectorAll(':scope > .row-actions')].map(group => ({ tree: [...group.children].map(name),
...css(group, 'display', 'columnGap', 'flexWrap', 'marginTop'),
buttons: [...group.children].map(button) })) })),
status: { text: text(status), ...css(status, 'fontSize', 'color', 'marginTop') },
surface: css(surfaces[0], 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
h2: css(surfaces[0].querySelector('h2'), 'fontSize', 'fontWeight', 'color', 'marginBottom'),
intro: css(surfaces[0].querySelector(':scope > p'), 'fontSize', 'color', 'lineHeight'),
// Its place below the columns (the concept's `.surface + .surface` 18px), whatever the columns' height.
policy: { tree: [...policy.children].map(name), heading: text(policy.querySelector(':scope > h2')),
box: { left: box(policy).left, width: box(policy).width,
gap: Math.round(policy.getBoundingClientRect().top - columns.getBoundingClientRect().bottom) },
chip: { text: text(chip), className: chip.className, ...css(chip, 'color', 'fontSize', 'borderTopColor', 'backgroundColor') },
rows: [...policy.querySelectorAll(':scope > .setting-row')].map(item => ({ tree: [...item.children].map(name),
text: [...item.firstElementChild.children].map(node => [name(node), text(node), node.localName === 'label' ? node.htmlFor : null]),
control: control(item) })),
warning: { text: text(warning), ...css(warning, 'fontSize', 'color', 'marginTop', 'marginBottom') } },
row: css(row, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderTopColor',
'marginTop'),
label: css(row.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
description: css(row.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'),
select: css(policy.querySelector('.setting-row > select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color',
'borderTopColor'),
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
}
const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
'records. A restore always keeps one.';
/**
* D103: "Back up" (the concept's "Simulate backup"), the status line from /backup/status, "Export JSON" (its "Export
* sample JSON") and the two secondary exports in a second `.row-actions.spaced` with the concept's plain buttons.
* D104: the toggle's line says what the policy does, the schedule's reason replaces the prototype's proposal line,
* Daily and Weekly are disabled, and the warning note is truthful. The fixture has no backup yet.
*/
function mapBackupsConcept(reference) {
const [database, portable] = reference.surfaces;
const [backup, restore] = database.actions[0].buttons;
const [exportButton, importButton] = portable.actions[0].buttons;
const secondary = ['Export all prompts JSON', 'Export saved pairs JSON'].map(label => ({ ...exportButton, text: label }));
return {
...reference,
surfaces: [
{ ...database, actions: [{ ...database.actions[0], buttons: [{ ...backup, text: 'Back up' }, restore] }] },
{ ...portable, tree: [...portable.tree, 'div.row-actions.spaced'],
actions: [{ ...portable.actions[0], buttons: [{ ...exportButton, text: 'Export JSON' }, importButton] },
{ ...portable.actions[0], tree: ['button', 'button'], buttons: secondary }] },
],
status: { ...reference.status, text: 'No backup recorded.' },
policy: { ...reference.policy,
rows: reference.policy.rows.map(item => (item.control.kind !== 'select'
// D104 (M2): what the safety-backup policy really does.
? { ...item, text: item.text.map(([node, value, target]) => [node, node === 'p' ? SAFETY_DESCRIPTION : value, target]) }
: { ...item,
text: item.text.map(([node, value, target]) => [node, node === 'p' ? SCHEDULE_REASON : value, target]),
control: { ...item.control, options: item.control.options.map(([label]) => [label, label !== 'Manual only']) } })),
warning: { ...reference.policy.warning, text: 'Restores are staged, migrated and validated before replacing live data.' } },
};
}
async function conceptBackups(page, theme) {
await openConcept(page, 'system', theme);
await page.locator('#main .section-nav').getByRole('button', { name: 'Backup & export', exact: true }).click();
await expect(page.locator('#main .settings-layout > .two-col > section.surface')).toHaveCount(2);
await page.mouse.move(0, 0);
return page.evaluate(measureBackups, '#main');
}
async function productionBackups(page, theme, status = 'No backup recorded.') {
await openProduction(page, 'system/backups', theme);
await expect(page.locator('#workspace-main .settings-layout > .two-col > section.surface')).toHaveCount(2);
// The status line and the policy row are their own reads: compare once both answered.
await expect(page.locator('#workspace-main #backup-status')).toHaveText(status);
await expect(page.locator('#workspace-main #backup-before')).toBeEnabled();
await page.mouse.move(0, 0);
return page.evaluate(measureBackups, '#workspace-main');
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`backup & export matches the concept ${theme} ${width}x${height}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptBackups(page, theme);
await page.screenshot({ path: testInfo.outputPath(`concept-backups-${theme}-${width}.png`), fullPage: true });
const production = await productionBackups(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-backups-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapBackupsConcept(concept), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
/** Runs in the page: the open dialog's title, body tree, grid, note, backup box and actions with the concept's tokens. */
function measureDialog() {
const dialog = document.querySelector('dialog#modal[open]');
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const body = dialog.querySelector('#modal-body');
const grid = body.querySelector(':scope > dl.meta-grid');
const note = body.querySelector(':scope > p.small');
const actions = body.querySelector(':scope > .dialog-actions');
const list = body.querySelector(':scope > .operation-list');
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name),
grid: grid && { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
note: note && { text: text(note), ...css(note, 'fontSize', 'color') },
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
list: list && { ...css(list, 'display', 'rowGap'),
rows: [...list.children].map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')),
description: text(row.querySelector('p')), buttons: [...row.querySelectorAll('button')].map(text),
...css(row, 'display', 'alignItems', 'paddingTop', 'borderTopColor') })) },
actions: actions && { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
};
}
const RESTORE_BACKUP_BOX = Object.freeze({ tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
['p', 'Downloads a copy of the prompts database before the restore replaces it.']], buttons: ['Back up now'] });
/**
* D105: the server's review in the concept's "Restore preview": no sample note; the four rows with the review's values,
* then "In the backup" and "In the library now"; the hold line in the concept's dialog `p.small` (its Import dialog's
* intro, `small`); the D55 backup box before the actions; "Restore" (the concept's "Simulate restore", still danger).
*/
function mapRestoreConcept(reference, small, rows) {
const [cancel, restore] = reference.actions.buttons;
return {
...reference,
tree: ['dl.meta-grid', 'p.small', 'div.notice', 'div.dialog-actions'],
grid: { ...reference.grid, rows },
note: { ...small, text: 'Restore replaces the whole library with this backup and runs alone in Activity. ' +
'Nothing changes until you restore. This review holds for 15 minutes.' },
backup: RESTORE_BACKUP_BOX,
actions: { ...reference.actions, buttons: [cancel, { ...restore, text: 'Restore' }] },
};
}
/** D15 and D55, as on Images: Rescan for "Scan an output folder", then "Scan for metadata"; no portable recipe import. */
function mapImportConcept(reference) {
return { ...reference,
note: { ...reference.note, text: 'Image inspection and output scanning have different effects. Choose the workflow you want.' },
list: { ...reference.list, rows: reference.list.rows.filter(row => row.title !== 'Import portable recipes').flatMap(row =>
row.title === 'Scan an output folder'
? [{ ...row, description: 'Find files added or removed in the output folders.', buttons: ['Rescan'] },
{ ...row, title: 'Scan for metadata' }] : [row]) } };
}
async function conceptDialog(page, theme, action, title) {
await conceptBackups(page, theme);
await page.locator(`#main .settings-layout [data-action="${action}"]`).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText(title);
await page.mouse.move(0, 0);
return page.evaluate(measureDialog);
}
/** Production's restore review of a fresh backup of this server's library; the review's own answer too. */
async function productionRestore(page, request, theme) {
const copy = await request.get('/prompt_manager/backup');
await expect(copy).toBeOK();
// That copy is this session's download now.
await productionBackups(page, theme, /^Downloaded backup · .+ · verified\.$/);
const chooser = page.waitForEvent('filechooser');
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
await page.locator('#workspace-main').getByRole('button', { name: 'Review restore…', exact: true }).click();
await (await chooser).setFiles({ name: 'prompts_backup_20261025_093000.db', mimeType: 'application/octet-stream',
buffer: await copy.body() });
const preview = await (await answer).json();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Restore preview');
await page.mouse.move(0, 0);
return { preview, production: await page.evaluate(measureDialog) };
}
const counts = summary => `${summary.prompts} ${summary.prompts === 1 ? 'prompt' : 'prompts'} · ${summary.image_links} ` +
`${summary.image_links === 1 ? 'image link' : 'image links'} · ${summary.pairs} ${summary.pairs === 1 ? 'saved pair' : 'saved pairs'}` +
` · ${summary.runs} ${summary.runs === 1 ? 'run' : 'runs'}`;
function restoreRows(preview, snapshot) {
return [['Snapshot', snapshot], ['Integrity', 'Verified'], ['Migration', 'Current schema'],
['Safety backup', `Created before replacing (${preview.safety_backup.destination_label})`],
['In the backup', counts(preview.summary)], ['In the library now', counts(preview.live)]];
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`restore preview and import dialogs match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
// The concept's own dialog `p.small` (its Import dialog's intro) is the reference for the review's note.
const conceptImport = await conceptDialog(page, theme, 'import', 'Import & inspect');
await page.screenshot({ path: testInfo.outputPath(`concept-import-${theme}-${width}.png`) });
const conceptRestore = await conceptDialog(page, theme, 'restore', 'Restore preview');
expect(conceptRestore.tree).toEqual(['p.demo-note', 'dl.meta-grid', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-restore-${theme}-${width}.png`) });
const { preview, production } = await productionRestore(page, request, theme);
await page.screenshot({ path: testInfo.outputPath(`production-restore-${theme}-${width}.png`) });
const snapshot = production.grid.rows[0][1];
expect(snapshot).toMatch(/^prompts_backup_20261025_093000.db · (\d+ KB|\d+\.\d MB) · .+$/);
const { text: _intro, ...small } = conceptImport.note;
expect(diffInventory(mapRestoreConcept(conceptRestore, small, restoreRows(preview, snapshot)), production)).toEqual([]);
await page.keyboard.press('Escape');
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
await page.locator('#workspace-main').getByRole('button', { name: 'Import / inspect file', exact: true }).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Import & inspect');
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath(`production-import-${theme}-${width}.png`) });
expect(diffInventory(mapImportConcept(conceptImport), await page.evaluate(measureDialog))).toEqual([]);
});
}
test('backup & export comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptBackups(page, 'dark');
const expected = mapBackupsConcept(concept);
await productionBackups(page, 'dark');
const measure = () => page.evaluate(measureBackups, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
expect(await paths()).toEqual([]);
// Unmapped, the section differs exactly on the D103 and D104 paths (the added row's own paths, then the rest).
const unmapped = diffInventory(concept, await measure()).map(item => item.path).sort();
const added = 'surfaces.1.actions.1.';
expect(unmapped.some(path => path.startsWith(added))).toBe(true);
expect(unmapped.filter(path => !path.startsWith(added))).toEqual([
'policy.rows.0.text.1.1', 'policy.rows.1.control.options.1.1', 'policy.rows.1.control.options.2.1', 'policy.rows.1.text.1.1',
'policy.warning.text',
'status.text', 'surfaces.0.actions.0.buttons.0.text', 'surfaces.1.actions.0.buttons.0.text', 'surfaces.1.actions.length',
'surfaces.1.tree.3', 'surfaces.1.tree.length']);
// A renamed label, a removed row, a lost danger mark and a swapped token are noticed.
await page.evaluate(() => { document.querySelector('#workspace-main .setting-row label').textContent = 'Safety backup'; });
expect(await paths()).toEqual(['policy.rows.0.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main .setting-row label').textContent = 'Create a safety backup before maintenance'; });
const undoRow = await mutate(page, { remove: ['#workspace-main .settings-layout > section.surface > .setting-row:last-of-type'] });
expect(await paths()).toEqual(expect.arrayContaining(['policy.rows.length', 'policy.tree.length']));
await undoRow();
await page.evaluate(() => { document.querySelector('#workspace-main button.danger').classList.remove('danger'); });
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.actions.0.buttons.1.className']));
await page.evaluate(() => { document.querySelector('#workspace-main .two-col .row-actions > button:nth-child(2)').classList.add('danger'); });
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
expect(await paths()).toEqual(expect.arrayContaining(['row.borderTopColor']));
await undoToken();
const undoChip = await mutate(page, { css: '#workspace-main .chip.blue { color: rgb(255, 0, 0); }' });
expect(await paths()).toEqual(['policy.chip.color']);
await undoChip();
expect(await paths()).toEqual([]);
// The restore review: a renamed term, a lost danger mark and the grid's token are noticed.
const conceptImport = await conceptDialog(page, 'dark', 'import', 'Import & inspect');
const conceptRestore = await conceptDialog(page, 'dark', 'restore', 'Restore preview');
const { preview, production } = await productionRestore(page, request, 'dark');
const { text: _intro, ...small } = conceptImport.note;
const expectedRestore = mapRestoreConcept(conceptRestore, small, restoreRows(preview, production.grid.rows[0][1]));
const restorePaths = async () => diffInventory(expectedRestore, await page.evaluate(measureDialog)).map(item => item.path);
expect(await restorePaths()).toEqual([]);
await page.evaluate(() => { document.querySelector('dialog#modal[open] dl.meta-grid dt').textContent = 'Backup'; });
expect(await restorePaths()).toEqual(['grid.rows.0.0']);
await page.evaluate(() => { document.querySelector('dialog#modal[open] dl.meta-grid dt').textContent = 'Snapshot'; });
await page.evaluate(() => { document.querySelector('dialog#modal[open] .dialog-actions .danger').classList.remove('danger'); });
expect(await restorePaths()).toEqual(expect.arrayContaining(['actions.buttons.1.className']));
await page.evaluate(() => { document.querySelector('dialog#modal[open] .dialog-actions button:last-child').classList.add('danger'); });
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await restorePaths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
expect(await restorePaths()).toEqual([]);
});
+5 -2
View File
@@ -172,8 +172,8 @@ function mapOverviewConcept(reference, overview) {
// D93: the real pending count; Review metadata opens Needs review (the concept's go-review).
'3 ambiguous image links': row => ({ ...row,
title: `${number(overview.needs_review.pending)} ambiguous image link${overview.needs_review.pending === 1 ? '' : 's'}` }),
// D93: no backup has been recorded (verified backups arrive with S8).
'Backup status': row => ({ ...row, description: 'No retained backup record.' }),
// D93 (S9): what GET /backup/status reports; the fixture has no backup yet.
'Backup status': row => ({ ...row, description: 'No backup recorded.' }),
// D93: there is no paging, so the shared settings are grid and density.
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
@@ -219,6 +219,9 @@ async function productionOverview(page, theme) {
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
// The thumbnail row is its own read (I2): compare once it answered.
await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails');
// So is the backup row (S9).
await expect(page.locator('#workspace-main .operation').filter({ has: page.locator('h3', { hasText: 'Backup status' }) })
.locator('p')).toHaveText('No backup recorded.');
await page.mouse.move(0, 0);
return page.evaluate(measureSystem, '#workspace-main');
}
@@ -0,0 +1,489 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
// System › Backup & export (#249 System S9 Task 24) on the S8 backend and the concept-shaped fixture: Back up and its
// status line (D103), the restore review (D105) and its job, the exports, Import / inspect and the recovery policy
// (D104). A restore here replaces this worker's own temp database; every test starts from a fresh concept fixture.
const workspace = '/prompt_manager/workspace';
const STATUS_URL = '/prompt_manager/backup/status';
const PREVIEW_URL = '/prompt_manager/restore/preview';
const APPLY_URL = '/prompt_manager/restore/apply';
const WHEN = { dateStyle: 'medium', timeStyle: 'short' };
const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA imports change ' +
'records. A restore always keeps one.';
test.use({ locale: 'en-US', timezoneId: 'UTC' });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
page.systemErrors = [];
page.on('pageerror', error => page.systemErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/release');
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
expect(page.systemErrors).toEqual([]);
});
const toast = page => page.locator('#workspace-toast');
const layoutOf = page => page.locator('#workspace-main .settings-layout');
const dialog = page => page.locator('dialog#modal[open]');
const statusLine = page => layoutOf(page).locator('#backup-status');
const settingsStatus = page => page.locator('#settings-status');
const save = page => page.getByRole('button', { name: 'Save preferences', exact: true });
const buttonOf = (page, name) => layoutOf(page).getByRole('button', { name, exact: true });
const settingOf = (page, label) => layoutOf(page).locator('.setting-row').filter({ has: page.locator(`label:text-is("${label}")`) });
async function serverJson(request, url) {
const response = await request.get(url);
await expect(response).toBeOK();
return response.json();
}
async function openBackups(page) {
// A fresh page each time: the drafts live for the page session (Q10), and a hash-only goto would not reload.
if (page.url().endsWith('#/system/backups')) await page.reload();
else await page.goto(`${workspace}#/system/backups`);
await expect(layoutOf(page).locator(':scope > .two-col > section.surface')).toHaveCount(2);
await expect(statusLine(page)).not.toHaveText('Checking backups…');
// The policy row is painted once the server settings were read.
await expect(settingOf(page, 'Create a safety backup before maintenance').locator('input')).toBeEnabled();
return layoutOf(page);
}
/** The status line the page should show for the server's current status (its own wording, in this locale). */
async function expectedStatus(page, request) {
const status = await serverJson(request, STATUS_URL);
return page.evaluate(([answer, options]) => {
const when = value => new Date(value).toLocaleString(undefined, options);
const parts = [];
if (answer.last_download) parts.push(`Downloaded backup · ${when(answer.last_download.at)} · ${answer.last_download.verified ? 'verified' : 'not verified'}.`);
if (answer.safety_backups.length) parts.push(`Safety backup · ${when(answer.safety_backups[0].modified_at)} · ${answer.safety_backups.length} on disk.`);
return parts.join(' ') || 'No backup recorded.';
}, [status, WHEN]);
}
/** A verified copy of the library as it is now (this records the session's last download, as Back up does). */
async function backupBytes(request) {
const response = await request.get('/prompt_manager/backup');
await expect(response).toBeOK();
expect(response.headers()['x-promptmanager-verify']).toBe('ok');
return response.body();
}
/** Pick `buffer` as the backup file through Review restore…'s file chooser; resolves with the preview's answer. */
async function chooseBackup(page, buffer, name = 'prompts_backup_20261025_093000.db') {
const chooser = page.waitForEvent('filechooser');
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL) && response.request().method() === 'POST');
await buttonOf(page, 'Review restore…').click();
await (await chooser).setFiles({ name, mimeType: 'application/octet-stream', buffer });
return answer;
}
const gridOf = page => dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
const libraryTotal = async request => (await serverJson(request, '/prompt_manager/library?limit=1')).total;
test('Back up downloads through the api client and revokes the object URL (D103)', async ({ page, request }) => {
await page.addInitScript(() => {
window.objectUrls = { created: [], revoked: [] };
const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL);
URL.createObjectURL = blob => { const url = create(blob); window.objectUrls.created.push(url); return url; };
URL.revokeObjectURL = url => { window.objectUrls.revoked.push(url); revoke(url); };
});
await openBackups(page);
await expect(statusLine(page)).toHaveText('No backup recorded.');
const download = page.waitForEvent('download');
const answer = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/backup');
await buttonOf(page, 'Back up').click();
expect((await answer).headers()['x-promptmanager-verify']).toBe('ok');
expect((await download).suggestedFilename()).toMatch(/^prompts_backup_\d{8}_\d{6}\.db$/);
await expect(toast(page)).toHaveText('Database backup downloaded.');
// The status line is read again: this session's verified download, as the server records it.
await expect(statusLine(page)).toHaveText(/^Downloaded backup · .+ · verified\.$/);
await expect(statusLine(page)).toHaveText(await expectedStatus(page, request));
await expect(buttonOf(page, 'Back up')).not.toHaveAttribute('aria-disabled', 'true');
const urls = await page.evaluate(() => window.objectUrls.created);
expect(urls).toHaveLength(1);
await expect.poll(() => page.evaluate(() => window.objectUrls.revoked)).toEqual(urls);
});
test('status line shows only server-reported backups (D103)', async ({ page, request }) => {
await openBackups(page);
await expect(statusLine(page)).toHaveText('No backup recorded.');
await expect(await request.post('/__test__/system/safety-backup')).toBeOK();
await openBackups(page);
await expect(statusLine(page)).toHaveText(/^Safety backup · .+ · 1 on disk\.$/);
await expect(statusLine(page)).toHaveText(await expectedStatus(page, request));
// A status the server could not report is never shown as "no backup".
await page.route(`**${STATUS_URL}`, route => route.fulfill({ status: 500, json: { success: false, error: 'Failed' } }));
await page.reload();
await expect(statusLine(page)).toHaveText('The backup status could not be read.');
});
test('Overview backup row uses the same status (D93)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/safety-backup')).toBeOK();
await backupBytes(request);
await page.goto(`${workspace}#/system/overview`);
const row = layoutOf(page).locator('.operation').filter({ has: page.locator('h3', { hasText: 'Backup status' }) });
const expected = await expectedStatus(page, request);
expect(expected).toMatch(/^Downloaded backup · .+ · verified\. Safety backup · .+ · 1 on disk\.$/);
await expect(row.locator('p')).toHaveText(expected);
await row.getByRole('button', { name: 'View backups', exact: true }).click();
await expect(page).toHaveURL(`${workspace}#/system/backups`);
await expect(statusLine(page)).toHaveText(expected);
});
test('Review restore stages the chosen file and shows the real summary with the backup box (D105)', async ({ page, request }) => {
const bytes = await backupBytes(request);
await expect(await request.post('/prompt_manager/save', { data: { text: 'S9 prompt saved after the backup' } })).toBeOK();
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
const answer = await chooseBackup(page, bytes);
const preview = await answer.json();
expect(preview.integrity).toBe('ok');
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
const counts = summary => `${summary.prompts.toLocaleString('en-US')} ${summary.prompts === 1 ? 'prompt' : 'prompts'} · ` +
`${summary.image_links} ${summary.image_links === 1 ? 'image link' : 'image links'} · ` +
`${summary.pairs} ${summary.pairs === 1 ? 'saved pair' : 'saved pairs'} · ${summary.runs} ${summary.runs === 1 ? 'run' : 'runs'}`;
const rows = await gridOf(page);
expect(rows.map(([term]) => term)).toEqual(['Snapshot', 'Integrity', 'Migration', 'Safety backup', 'In the backup', 'In the library now']);
expect(rows[0][1]).toMatch(/^prompts_backup_20261025_093000.db · (\d+ KB|\d+\.\d MB) · .+$/);
expect(rows.slice(1)).toEqual([['Integrity', 'Verified'], ['Migration', 'Current schema'],
['Safety backup', `Created before replacing (${preview.safety_backup.destination_label})`],
['In the backup', counts(preview.summary)], ['In the library now', counts(preview.live)]]);
// The prompt saved after the backup is the difference the review shows.
expect(preview.live.prompts).toBe(preview.summary.prompts + 1);
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Restore replaces the whole library with this backup ' +
'and runs alone in Activity. Nothing changes until you restore. This review holds for 15 minutes.');
await expect(dialog(page).locator('.notice h3')).toHaveText('Back up the database first (recommended)');
await expect(dialog(page).locator('.notice p')).toHaveText('Downloads a copy of the prompts database before the restore replaces it.');
const actions = dialog(page).locator('.dialog-actions > button');
await expect(actions).toHaveText(['Cancel', 'Restore']);
await expect(actions.nth(1)).toHaveClass('danger');
// The backup box downloads a copy and keeps the review open.
const download = page.waitForEvent('download');
await dialog(page).getByRole('button', { name: 'Back up now', exact: true }).click();
await download;
await expect(toast(page)).toHaveText('Database backup downloaded.');
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
// A review writes nothing: the rows and the media are as they were.
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
});
test('Cancel discards the stage; Escape too', async ({ page, request }) => {
const bytes = await backupBytes(request);
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
let discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
expect((await discarded).status()).toBe(200);
await expect(dialog(page)).toHaveCount(0);
await expect(buttonOf(page, 'Review restore…')).toBeFocused();
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
await page.keyboard.press('Escape');
expect((await discarded).status()).toBe(200);
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
});
test('Restore starts the job, opens Activity; views reload after it finishes', async ({ page, request }) => {
const bytes = await backupBytes(request);
const restoredTotal = await libraryTotal(request);
await expect(await request.post('/prompt_manager/save', { data: { text: 'S9 prompt the restore removes' } })).toBeOK();
expect(await libraryTotal(request)).toBe(restoredTotal + 1);
await openBackups(page);
const libraryCount = page.locator('aside.sidebar a[data-route="library"] .nav-count');
await expect(libraryCount).toHaveText((restoredTotal + 1).toLocaleString('en-US'));
await chooseBackup(page, bytes);
const applied = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
const { job } = await (await applied).json();
expect([job.kind, job.title, job.cancellable]).toEqual(['restore', 'Database restore', false]);
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
await expect(toast(page)).toHaveText(/^Database restore finished · Restored \d+ prompts?, /);
// The library is the backup's again, and the page says so without a reload: the sidebar counts were read again.
expect(await libraryTotal(request)).toBe(restoredTotal);
await expect(libraryCount).toHaveText(restoredTotal.toLocaleString('en-US'));
expect((await serverJson(request, '/__test__/system/backups')).safety).toHaveLength(1);
// The Activity job view stayed, and Backup & export now names the safety backup the restore kept.
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
await page.goto(`${workspace}#/system/backups`);
await expect(statusLine(page)).toHaveText(/Safety backup · .+ · 1 on disk\.$/);
});
test('a finished restore shows the open page again from the restored data', async ({ page, request }) => {
const bytes = await backupBytes(request);
const restoredTotal = await libraryTotal(request);
await expect(await request.post('/prompt_manager/save', { data: { text: 'S9 prompt the restore removes' } })).toBeOK();
await openBackups(page);
await chooseBackup(page, bytes);
const preview = page.locator('dialog#modal[open] #modal-title');
await expect(preview).toHaveText('Restore preview');
// The restore is held at its start, so the reader is on the Library (showing the prompt) when it finishes.
await expect(await request.post('/__test__/hold', { data: { path: APPLY_URL } })).toBeOK();
const applied = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[APPLY_URL]).toBe(1);
await page.evaluate(() => { window.location.hash = '#/library'; });
const card = page.locator('#workspace-main article.prompt-card').filter({ hasText: 'S9 prompt the restore removes' });
await expect(card).toHaveCount(1);
await expect(await request.post('/__test__/release')).toBeOK();
await applied;
await expect(toast(page)).toHaveText(/^Database restore finished · /);
// The shelf was read again from the restored library, on the same route.
await expect(card).toHaveCount(0);
await expect(page).toHaveURL(`${workspace}#/library`);
expect(await libraryTotal(request)).toBe(restoredTotal);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
const answer = await chooseBackup(page, Buffer.from('this is not a database'), 'notes.db');
expect(answer.status()).toBe(400);
const body = await answer.json();
expect(body.code).toBe('invalid-backup');
await expect(toast(page)).toHaveText(`This file can't be restored: ${body.error.replace(/\.?$/, '.')}`);
await expect(dialog(page)).toHaveCount(0);
await expect(buttonOf(page, 'Review restore…')).not.toHaveAttribute('aria-disabled', 'true');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
});
test('Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103)', async ({ page }) => {
const layout = await openBackups(page);
const portable = layout.locator('section.surface').filter({ has: page.locator('h2', { hasText: 'Portable export' }) });
expect(await portable.locator('.row-actions > button').allTextContents())
.toEqual(['Export JSON', 'Import / inspect file', 'Export all prompts JSON', 'Export saved pairs JSON']);
for (const [label, url, name, kind] of [
['Export JSON', '/prompt_manager/library/export', /^prompt-manager-library-v1\.json$/, 'prompt-manager-library'],
['Export all prompts JSON', '/prompt_manager/export', /^prompt_manager_\d{8}_\d{6}\.json$/, null],
['Export saved pairs JSON', '/prompt_manager/pairs/export', /^prompt-pairs\.json$/, 'prompt-manager-pairs']]) {
const download = page.waitForEvent('download');
const answer = page.waitForResponse(response => new URL(response.url()).pathname === url);
await portable.getByRole('button', { name: label, exact: true }).click();
expect((await answer).status()).toBe(200);
const file = await download;
expect(file.suggestedFilename()).toMatch(name);
const parsed = JSON.parse(await (await file.createReadStream()).toArray().then(chunks => Buffer.concat(chunks).toString('utf8')));
if (kind) expect(parsed.kind).toBe(kind);
else expect(Array.isArray(parsed.prompts)).toBe(true);
}
// A failed export says so and saves nothing.
await page.route('**/prompt_manager/pairs/export', route => route.fulfill({ status: 500, json: { success: false } }));
await portable.getByRole('button', { name: 'Export saved pairs JSON', exact: true }).click();
await expect(toast(page)).toHaveText('The export could not be downloaded. Nothing was saved to this device.');
});
test('Import / inspect lists Inspect, Rescan and the shared scan review (D15, D55)', async ({ page, request }) => {
await openBackups(page);
const open = async () => {
await buttonOf(page, 'Import / inspect file').click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Import & inspect');
};
await open();
const rows = dialog(page).locator('.operation-list > .operation');
expect(await rows.locator('h3').allTextContents()).toEqual(['Inspect a local image', 'Scan an output folder', 'Scan for metadata']);
expect(await rows.locator('button').allTextContents()).toEqual(['Open inspector', 'Rescan', 'Review scan']);
// Rescan reads the output folders again and says what it found; while it runs, a reopened dialog says so.
await expect(await request.post('/__test__/hold', { data: { path: '/prompt_manager/gallery/rescan' } })).toBeOK();
const rescan = page.waitForResponse(response => response.url().endsWith('/prompt_manager/gallery/rescan'));
await rows.getByRole('button', { name: 'Rescan', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Rescanning output folders…');
await open();
await expect(rows.nth(1).locator('button')).toHaveText('Rescanning…');
await expect(rows.nth(1).locator('button')).toHaveAttribute('aria-disabled', 'true');
await expect(await request.post('/__test__/release')).toBeOK();
expect((await rescan).status()).toBe(200);
await expect(toast(page)).toHaveText(/^Rescan finished\. \d+ files? in \d+ output folders?\.$/);
await expect(rows.nth(1).locator('button')).toHaveText('Rescan');
// Scan for metadata opens the shared review over the server's output folders; Cancel starts nothing.
await dialog(page).getByRole('button', { name: 'Review scan', exact: true }).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review metadata scan');
await expect(dialog(page).locator('dl.meta-grid dd').first()).toHaveText(/^\d+ output folders?$/);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
// Open inspector goes to Inspect a file.
await open();
await dialog(page).getByRole('button', { name: 'Open inspector', exact: true }).click();
await expect(page).toHaveURL(`${workspace}#/inspect`);
await expect(dialog(page)).toHaveCount(0);
});
test('safety backup toggle saves through Save preferences with revision (D104)', async ({ page, request }) => {
const before = await serverJson(request, '/prompt_manager/settings');
expect(before.settings.safety_backup).toBe(true);
await openBackups(page);
const row = settingOf(page, 'Create a safety backup before maintenance');
// D104: what the policy really does (the concept's "Preview a verified backup…" line does not say it).
await expect(row.locator('p').first()).toHaveText(SAFETY_DESCRIPTION);
const toggle = row.locator('input');
await expect(toggle).toBeChecked();
await toggle.uncheck();
await expect(settingsStatus(page)).toHaveText('Unsaved changes.');
const saved = page.waitForRequest(item => item.url().endsWith('/prompt_manager/settings') && item.method() === 'POST');
await save(page).click();
const body = (await saved).postDataJSON();
expect(body).toEqual({ safety_backup: false, expected_revision: before.revision });
await expect(settingsStatus(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, '/prompt_manager/settings')).settings.safety_backup).toBe(false);
// A maintenance review now says so.
const plan = await request.post('/prompt_manager/maintenance/preview', { data: { operation: 'optimize', options: {} } });
expect((await plan.json()).backup.policy).toBe('off');
await openBackups(page);
await expect(toggle).not.toBeChecked();
// A configuration changed elsewhere is a conflict, never a blind write.
await toggle.check();
await expect(await request.post('/prompt_manager/settings', { data: { result_timeout: 7,
expected_revision: (await serverJson(request, '/prompt_manager/settings')).revision } })).toBeOK();
await save(page).click();
await expect(settingsStatus(page)).toHaveText('Settings changed elsewhere. Reload this section to see them.');
expect((await serverJson(request, '/prompt_manager/settings')).settings.safety_backup).toBe(false);
});
test('schedule offers Manual only; Daily/Weekly disabled with reason (D104)', async ({ page }) => {
await openBackups(page);
const schedule = settingOf(page, 'Scheduled database backups');
expect(await schedule.locator('option').evaluateAll(options => options.map(option => [option.textContent, option.disabled])))
.toEqual([['Manual only', false], ['Daily', true], ['Weekly', true]]);
await expect(schedule.locator('select')).toHaveValue('Manual only');
await expect(schedule.locator('p')).toHaveText('Daily and Weekly are not available yet: back up with Back up whenever you choose.');
const policy = layoutOf(page).locator(':scope > section.surface');
await expect(policy.locator('h2')).toHaveText('Recovery policy Proposed');
await expect(policy.locator('h2 > span.chip.blue')).toHaveText('Proposed');
await expect(policy.locator('p.warning-note')).toHaveText('Restores are staged, migrated and validated before replacing live data.');
// Nothing on the schedule is ever drafted.
await expect(settingsStatus(page)).toHaveText('No unsaved changes.');
});
test('a failed settings read leaves the policy disabled with the reason', async ({ page }) => {
await page.route('**/prompt_manager/settings', route => (route.request().method() === 'GET'
? route.fulfill({ status: 500, json: { success: false, error: 'Failed' } }) : route.continue()));
await page.goto(`${workspace}#/system/backups`);
const row = settingOf(page, 'Create a safety backup before maintenance');
await expect(row.locator('p.small')).toHaveText('Server settings could not be read.');
await expect(row.locator('input')).toBeDisabled();
});
test('the legacy admin Backup and Restore keep working', async ({ page, request }) => {
const bytes = await backupBytes(request);
const restored = await request.post('/prompt_manager/restore', { multipart: { database_file: {
name: 'prompts_backup.db', mimeType: 'application/octet-stream', buffer: bytes } } });
await expect(restored).toBeOK();
expect((await restored.json()).success).toBe(true);
await page.goto('/prompt_manager/admin');
await expect(page.locator('#restoreFileInput')).toHaveCount(1);
});
/** Start a fake running job (a metadata scan another tab started), which an exclusive restore must wait for. */
async function runningScan(request) {
const response = await request.post('/__test__/jobs/start', { data: { kind: 'scan', title: 'Metadata scan', total: 4 } });
await expect(response).toBeOK();
return (await response.json()).job;
}
const stagesOf = async request => (await serverJson(request, '/__test__/system/backups')).stages;
const deleted = page => page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) &&
response.request().method() === 'DELETE');
test('a refused restore reopens its review: Restore retries it, Discard releases it (I1)', async ({ page, request }) => {
const bytes = await backupBytes(request);
await openBackups(page);
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
const scan = await runningScan(request);
const refused = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
expect((await refused).status()).toBe(409);
await expect(toast(page)).toHaveText('Another job is running: Metadata scan.');
// The same review is open again, its stage still held: Restore retries, Discard releases it.
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Discard', 'Restore']);
expect(await stagesOf(request)).toBe(1);
await expect(await request.post(`/__test__/jobs/${scan.id}/finish`, { data: { state: 'finished' } })).toBeOK();
const applied = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
const { job } = await (await applied).json();
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
await expect(toast(page)).toHaveText(/^Database restore finished · /);
expect(await stagesOf(request)).toBe(0);
});
test('two refused restores in a row, then a third Review restore… still opens the preview (I1)', async ({ page, request }) => {
const bytes = await backupBytes(request);
await openBackups(page);
await runningScan(request);
for (const leave of ['discard', 'escape']) {
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
const refused = page.waitForResponse(response => response.url().endsWith(APPLY_URL));
await dialog(page).getByRole('button', { name: 'Restore', exact: true }).click();
expect((await refused).status()).toBe(409);
await expect(dialog(page).locator('.dialog-actions > button')).toHaveText(['Discard', 'Restore']);
const released = deleted(page);
if (leave === 'discard') await dialog(page).getByRole('button', { name: 'Discard', exact: true }).click();
else await page.keyboard.press('Escape');
expect((await released).status()).toBe(200);
await expect(dialog(page)).toHaveCount(0);
expect(await stagesOf(request)).toBe(0);
}
const answer = await chooseBackup(page, bytes);
expect((await answer).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect(await stagesOf(request)).toBe(1);
});
test('× and leaving the section or the page discard the stage (M1)', async ({ page, request }) => {
const bytes = await backupBytes(request);
await openBackups(page);
const leaves = [
['×', () => page.locator('#modal-close').click()],
// The modal covers the page, so the section changes as a link or the Back button changes it: by its hash.
['section', () => page.evaluate(() => { window.location.hash = '#/system/maintenance'; })],
['route', () => page.evaluate(() => { window.location.hash = '#/images'; })],
];
for (const [name, leave] of leaves) {
if (!page.url().endsWith('#/system/backups')) {
await page.goto(`${workspace}#/system/backups`);
await expect(statusLine(page)).not.toHaveText('Checking backups…');
}
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect(await stagesOf(request), name).toBe(1);
// A keepalive DELETE (it may outlive the page) is asserted by its request and the server's stage count.
const released = page.waitForRequest(item => item.url().includes(`${PREVIEW_URL}/`) && item.method() === 'DELETE');
await leave();
await released;
await expect(dialog(page)).toHaveCount(0);
await expect.poll(() => stagesOf(request), { message: name }).toBe(0);
}
});
test('a safety backup file (no .db extension) can be picked and reviewed (M3)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/safety-backup')).toBeOK();
const bytes = await backupBytes(request);
await openBackups(page);
// The picker lists every file; the server decides what is a database.
await expect(page.locator('#restore-file')).not.toHaveAttribute('accept', /./);
const answer = await chooseBackup(page, bytes, 'prompts.db.backup_20261025_093000_000000');
expect((await answer).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await gridOf(page))[0][1]).toMatch(/^prompts\.db\.backup_20261025_093000_000000 · \d+ KB · .+$/);
});
+4 -1
View File
@@ -36,6 +36,9 @@ const layoutOf = page => page.locator('#workspace-main .settings-layout');
async function openOverview(page) {
await page.goto(`${workspace}#/system/overview`);
await expect(layoutOf(page).locator(':scope > .summary-metrics')).toBeVisible();
// The Backup status row is its own read of /backup/status (S9): compare once it answered.
await expect(layoutOf(page).locator('.operation').filter({ has: page.locator('h3', { hasText: 'Backup status' }) })
.locator('p')).not.toHaveText('Checking backups…');
return layoutOf(page);
}
@@ -67,7 +70,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
]);
expect(await rowsOf(layout, 'Library health')).toEqual([
['0 ambiguous image links', 'Review metadata before assigning a positive / negative pair.', 'Review metadata'],
['Backup status', 'No retained backup record.', 'View backups'],
['Backup status', 'No backup recorded.', 'View backups'],
// D129: the fixture's database is configured explicitly, so it reads as config.json's choice and does not move.
['Library location', 'browser.db · The database path set in config.json.', 'Details'],
['No missing thumbnails', 'Generate only missing previews when necessary.', 'Review thumbnails'],
+6 -4
View File
@@ -464,17 +464,19 @@ test('System is native: the concept shell, section navigation, sections, hand-of
await expect(button).toBeFocused();
// S2 Task 6: Overview is native (workspace-system.spec.mjs), and so are Appearance (S3 Task 8) and Sources & scans
// (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser), Integrations (S10
// Task 27, workspace-system-integrations.spec.mjs) and Maintenance (S7 Task 19,
// workspace-system-maintenance.spec.mjs); every other section hands off.
// Task 27, workspace-system-integrations.spec.mjs), Maintenance (S7 Task 19,
// workspace-system-maintenance.spec.mjs) and Backup & export (S9 Task 24, workspace-system-backups.spec.mjs);
// every other section hands off.
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'],
sources: ['div.notice', 'section.surface', 'div.save-row'],
integrations: ['section.surface', 'section.surface', 'div.save-row'],
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'] }[hash]
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],
backups: ['div.two-col', 'section.surface', 'div.save-row'] }[hash]
?? ['section.surface handoff', 'div.save-row']);
if (['overview', 'appearance', 'sources', 'integrations', 'maintenance'].includes(hash)) continue;
if (['overview', 'appearance', 'sources', 'integrations', 'maintenance', 'backups'].includes(hash)) continue;
const handoff = layout.locator('section.handoff');
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
+40
View File
@@ -50,6 +50,12 @@ read. These per-server hooks only change what a System test needs on top:
rows (a read-only dump) and of every media file under ``output`` and
``input`` (not the automatic previews in ``thumbnails``), so a test proves
a Cancel wrote nothing.
- ``POST /__test__/system/safety-backup`` (S9) writes one verified safety
backup beside the database, as a maintenance apply does;
``GET /__test__/system/backups`` answers ``{safety: [names], stages: n}``:
the ``<db>.backup_*`` files and the held ``.pm-restore-*`` stage folders.
Reset drops every held restore review, removes the safety backups and
forgets the session's last download.
Delays use the shared request holds (``request_hold.py``).
"""
@@ -151,11 +157,45 @@ def register_system_commands(app, routes, api, root: Path):
staged.write_bytes(config_bytes)
os.replace(staged, config_path)
def backup_files():
db_path = Path(api.db.model.db_path)
safety = sorted(
path.name
for path in db_path.parent.glob(db_path.name + ".backup_*")
if path.is_file()
)
stages = [
path
for path in db_path.parent.glob(".pm-restore-*")
if path.is_dir() and not path.name.startswith(".pm-restore-upload-")
]
return db_path, safety, stages
async def reset_backups():
await api._discard_held(api._restore_plans.pop_all())
db_path, safety, _stages = backup_files()
for name in safety:
(db_path.parent / name).unlink(missing_ok=True)
api._last_backup_download = None
@routes.post("/__test__/system/reset")
async def system_reset(request):
reset()
await reset_backups()
return web.json_response({"success": True})
@routes.post("/__test__/system/safety-backup")
async def safety_backup(request):
from py.api.maintenance import take_safety_backup
name = await api._run_in_executor(take_safety_backup, api.db.model)
return web.json_response({"success": True, "name": name})
@routes.get("/__test__/system/backups")
async def backups(request):
_db_path, safety, stages = backup_files()
return web.json_response({"safety": safety, "stages": len(stages)})
@routes.post("/__test__/system/fail")
async def system_fail(request):
data = await request.json()
+1 -1
View File
@@ -126,7 +126,7 @@ test('held legacy jobs read "(admin page)" with no outcomes', async () => {
test('unknown and generic kinds show no tiles of another kind', async () => {
const { kindOf, viewOf } = await source;
for (const kind of ['restore', '', 'constructor', '__proto__', 'health']) {
for (const kind of ['backup', '', 'constructor', '__proto__', 'health']) {
const view = kindOf(kind);
assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []], kind);
assert.deepEqual([view.log, view.cancelLabel], ['Job log', 'Cancel job']);
+1 -1
View File
@@ -344,7 +344,7 @@ test('unknown kinds get the generic view without the scan tiles', async () => {
['no_metadata', 'No usable metadata'], ['known', 'Already known']]);
assert.deepEqual([...scan.openable].sort(), ['added', 'known']);
assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']);
for (const kind of ['restore', '', 'constructor', '__proto__']) {
for (const kind of ['backup', '', 'constructor', '__proto__']) {
const view = jobView(kind);
assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []]);
assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']);
+286
View File
@@ -0,0 +1,286 @@
// System › Backup & export (#249 System S9 Task 24): the concept's copy (app.js 691–693), the status line from
// GET /backup/status (D103), the restore review's rows from the S8 preview (D105), its upload and refusals, the export
// list, the restore job's Activity copy and the reload after a finished restore.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { ApiError } = require('../../web/js/api-client.js');
const backups = import('../../web/js/workspace/system-backups.mjs');
const kinds = import('../../web/js/workspace/activity-kinds.mjs');
const scan = import('../../web/js/workspace/activity-scan.mjs');
const dialogs = import('../../web/js/workspace/workspace-dialogs.mjs');
const CONCEPT = fs.readFileSync(path.join(__dirname, '../../docs/concepts/workspace/app.js'), 'utf8');
// The module's code without its comments (which name the concept's prototype labels they replace).
const SOURCE = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/system-backups.mjs'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const conceptSection = () => {
const start = CONCEPT.indexOf('case "backups":');
return CONCEPT.slice(start, CONCEPT.indexOf('break;', start));
};
test('the section keeps the concept copy: surfaces, descriptions and the two kept labels', () => {
const body = conceptSection();
for (const text of ['Database backup', 'A complete recovery snapshot, including prompt pairs, metadata and image links. Media files are separate.',
'Review restore…', 'Portable export', 'Export reusable recipes and tags as versioned JSON. This is distinct from a complete database backup.',
'Import / inspect file', 'Recovery policy', 'Proposed', 'Create a safety backup before maintenance', 'Scheduled database backups',
'Manual only', 'Daily', 'Weekly']) {
assert.ok(body.includes(text), `concept lacks ${text}`);
assert.ok(SOURCE.includes(text), `production lacks ${text}`);
}
// D103/D104: prototype copy is never shipped.
// D104 (M2): the toggle's concept line ("Preview a verified backup…") misstates the policy and is replaced too.
for (const sample of ['Simulate backup', 'Sample backup', 'Export sample JSON', 'Production feature proposal', 'A restore would be',
'Preview a verified backup before applying operations that change records.']) {
assert.ok(body.includes(sample));
assert.ok(!SOURCE.includes(sample), `production ships ${sample}`);
}
});
test('status line shows only server-reported backups (D103)', async () => {
const { backupStatusText, NO_BACKUP, STATUS_LOADING, STATUS_FAILED, whenText } = await backups;
assert.equal(backupStatusText(undefined), STATUS_LOADING);
assert.equal(backupStatusText(null), STATUS_FAILED);
assert.equal(backupStatusText({ success: true, safety_backups: [], last_download: null }), NO_BACKUP);
assert.equal(NO_BACKUP, 'No backup recorded.');
// A time the server did not send in a readable form names nothing.
assert.equal(backupStatusText({ safety_backups: [{ name: 'x', modified_at: 'soon' }], last_download: { at: 7, verified: true } }),
NO_BACKUP);
const at = '2026-10-25T09:30:00+00:00';
assert.equal(backupStatusText({ safety_backups: [], last_download: { at, bytes: 10, verified: true } }),
`Downloaded backup · ${whenText(at)} · verified.`);
assert.equal(backupStatusText({ safety_backups: [], last_download: { at, bytes: 10, verified: false } }),
`Downloaded backup · ${whenText(at)} · not verified.`);
const safety = [{ name: 'prompts.db.backup_2', bytes: 1, modified_at: at }, { name: 'prompts.db.backup_1', bytes: 1, modified_at: at }];
assert.equal(backupStatusText({ safety_backups: safety, last_download: null }), `Safety backup · ${whenText(at)} · 2 on disk.`);
assert.equal(backupStatusText({ safety_backups: safety.slice(0, 1), last_download: { at, verified: true } }),
`Downloaded backup · ${whenText(at)} · verified. Safety backup · ${whenText(at)} · 1 on disk.`);
assert.equal(whenText(undefined), null);
});
const PREVIEW = Object.freeze({
success: true, token: 'r'.repeat(43), expires_at: '2026-10-25T12:15:00+00:00',
summary: { prompts: 1234, positives: 900, pairs: 3, archived_pairs: 0, image_links: 56, runs: 1, saved_views: 2 },
schema: { migrated: true, library_state: true }, integrity: 'ok', documents: { repaired: 0, cleared: 0, images: 0 },
live: { prompts: 1, positives: 1, pairs: 0, archived_pairs: 0, image_links: 0, runs: 0, saved_views: 0 }, size: 4096,
safety_backup: { destination_label: 'prompts.db.backup_<date and time>' },
});
test('Review restore shows the real summary in the concept rows (D105)', async () => {
const { restoreGrid, restoreNote, countsText, integrityText, snapshotText, whenText } = await backups;
const file = { name: 'prompts_backup_20261025_093000.db', size: 3 * 2 ** 20, lastModified: Date.parse('2026-10-25T09:30:00Z') };
assert.deepEqual(restoreGrid(PREVIEW, file), [
['Snapshot', `prompts_backup_20261025_093000.db · 3.0 MB · ${whenText('2026-10-25T09:30:00.000Z')}`],
['Integrity', 'Verified'],
['Migration', 'Upgraded to the current schema'],
['Safety backup', 'Created before replacing (prompts.db.backup_<date and time>)'],
['In the backup', '1,234 prompts · 56 image links · 3 saved pairs · 1 run'],
['In the library now', '1 prompt · 0 image links · 0 saved pairs · 0 runs'],
]);
assert.equal(restoreGrid({ ...PREVIEW, schema: { migrated: false } }, file)[2][1], 'Current schema');
assert.equal(restoreGrid({ ...PREVIEW, safety_backup: null }, file)[3][1], 'Created before replacing');
assert.equal(countsText({}), 'Not reported');
assert.equal(snapshotText({ name: 'a.db' }), 'a.db');
assert.equal(integrityText({ integrity: 'ok', documents: { repaired: 2, cleared: 1, images: 1 } }),
'Verified · repaired 2 damaged workflow documents · could not repair 1 workflow document: 1 image shows no workflow');
assert.equal(integrityText({ integrity: 'ok', documents: { repaired: 1, cleared: 2, images: 3 } }),
'Verified · repaired 1 damaged workflow document · could not repair 2 workflow documents: 3 images show no workflow');
assert.equal(integrityText({}), 'Not verified');
const now = Date.parse('2026-10-25T12:00:00+00:00');
assert.equal(restoreNote(PREVIEW, { now }), 'Restore replaces the whole library with this backup and runs alone in Activity. ' +
'Nothing changes until you restore. This review holds for 15 minutes.');
assert.equal(restoreNote({ expires_at: 'x' }, { now }), 'Restore replaces the whole library with this backup and runs alone ' +
'in Activity. Nothing changes until you restore.');
});
function response(status, body, headers = {}) {
return { ok: status >= 200 && status < 300, status, json: async () => {
if (body === undefined) throw new SyntaxError('not JSON');
return body;
}, headers: { get: name => headers[name] ?? null }, blob: async () => new Blob(['x']) };
}
test('invalid file shows the validator message; a refusal names its job; a lost answer stages nothing', async () => {
const { uploadRestore, reviewRefusal, REVIEW_FAILED, PREVIEW_URL } = await backups;
const file = new File(['not a database'], 'bad.db');
let seen;
const refuse = async (url, init) => { seen = [url, init.method, init.cache]; return response(400,
{ success: false, code: 'invalid-backup', error: 'Not a SQLite database file' }); };
const invalid = await uploadRestore(file, { fetchUrl: refuse, ApiError }).catch(error => error);
assert.deepEqual(seen, [PREVIEW_URL, 'POST', 'no-store']);
assert.equal(reviewRefusal(invalid), "This file can't be restored: Not a SQLite database file.");
const busy = await uploadRestore(file, { ApiError, fetchUrl: async () => response(409,
{ success: false, code: 'busy', error: 'Busy', running: { id: 'j1', title: 'Metadata scan' } }) }).catch(error => error);
assert.deepEqual(reviewRefusal(busy), ['Another job is running: ', { text: 'Metadata scan', href: '#/activity/j1' }, '.']);
const full = await uploadRestore(file, { ApiError, fetchUrl: async () => response(429,
{ success: false, code: 'too-large', error: 'Two restores are already being reviewed. Discard one first.' }) })
.catch(error => error);
// I1: the server's "Discard one first" names nothing this page can do; the toast says what the user can.
assert.equal(reviewRefusal(full), (await backups).REVIEWS_FULL);
const proxy = await uploadRestore(file, { ApiError, fetchUrl: async () => response(502, undefined) }).catch(error => error);
assert.equal(reviewRefusal(proxy), REVIEW_FAILED);
const odd = await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, { success: true }) }).catch(error => error);
assert.equal(reviewRefusal(odd), REVIEW_FAILED);
assert.equal(reviewRefusal(new Error('offline')), REVIEW_FAILED);
assert.deepEqual(await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, PREVIEW) }), PREVIEW);
});
test('Cancel discards the stage through the api client and never throws', async () => {
const { discardRestore, PREVIEW_URL } = await backups;
const calls = [];
await discardRestore('a/b', { fetchUrl: async (url, init) => { calls.push([url, init.method]); return response(200, {}); } });
await discardRestore('gone', { fetchUrl: async () => { throw new TypeError('offline'); } });
assert.deepEqual(calls, [[`${PREVIEW_URL}/a%2Fb`, 'DELETE']]);
});
test('Export JSON downloads /library/export; secondary buttons download /export and /pairs/export (D103)', async () => {
const { EXPORTS } = await backups;
assert.deepEqual(EXPORTS.map(([label, url]) => [label, url]), [
['Export JSON', '/prompt_manager/library/export'],
['Export all prompts JSON', '/prompt_manager/export'],
['Export saved pairs JSON', '/prompt_manager/pairs/export'],
]);
});
test('a failed export toasts and saves nothing', async () => {
const { downloadExport, EXPORTS, EXPORT_FAILED } = await backups;
const toasts = [];
assert.equal(await downloadExport(EXPORTS[1], { fetchUrl: async () => response(500, {}), notify: text => toasts.push(text) }), false);
const aborted = AbortSignal.abort();
assert.equal(await downloadExport(EXPORTS[1], { fetchUrl: async () => { throw new Error('x'); }, notify: text => toasts.push(text),
signal: aborted }), false);
assert.deepEqual(toasts, [EXPORT_FAILED]);
});
test('Restore starts the job: Activity names it and ends with the server line', async () => {
const { finishToast, outcomeLine, pillParts, canCancel, startToast } = await kinds;
const job = { id: 'j', kind: 'restore', title: 'Database restore', state: 'running', cancellable: false, progress: 0, total: 0,
counters: { errors: 0 }, status_line: 'Restoring the database' };
assert.equal(startToast(job), 'Database restore started.');
assert.equal(pillParts(job).label, 'Restoring the database');
assert.equal(canCancel(job), false);
assert.equal(outcomeLine(job), '');
const done = { ...job, state: 'finished', status_line: 'Restored 3 prompts, 1 image link, 0 saved pairs and 0 runs' };
assert.equal(finishToast(done), 'Database restore finished · Restored 3 prompts, 1 image link, 0 saved pairs and 0 runs');
assert.equal(outcomeLine(done), done.status_line);
const failed = { ...job, state: 'failed', status_line: 'The library changed since the review; nothing was changed. Review the restore again.' };
assert.equal(finishToast(failed), `Database restore failed · ${failed.status_line}`);
assert.equal(finishToast({ ...job, state: 'failed', status_line: '' }), 'Database restore failed.');
});
test('views reload once after a restore this page saw finish; a failed restore changes nothing', async () => {
const { onRestoreFinished } = await backups;
let list = [];
const listeners = new Set();
const activity = { jobs: () => list, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
const emit = next => { list = next; for (const listener of listeners) listener(); };
let reloads = 0;
const stop = onRestoreFinished(activity, () => { reloads += 1; });
emit([{ id: 'old', kind: 'restore', state: 'finished' }]);
assert.equal(reloads, 0, 'a restore that ended before this page saw it run');
emit([{ id: 'a', kind: 'restore', state: 'running' }, { id: 's', kind: 'scan', state: 'running' }]);
emit([{ id: 'a', kind: 'restore', state: 'finished' }, { id: 's', kind: 'scan', state: 'finished' }]);
emit([{ id: 'a', kind: 'restore', state: 'finished' }]);
assert.equal(reloads, 1);
emit([{ id: 'b', kind: 'restore', state: 'running' }]);
emit([{ id: 'b', kind: 'restore', state: 'failed' }]);
assert.equal(reloads, 1);
stop();
assert.equal(listeners.size, 0);
});
test('a downloaded backup the server could not verify says so', async () => {
const { downloadDatabaseBackup, BACKUP_DONE, backupUnverified } = await scan;
const toasts = [];
const body = globalThis.document;
globalThis.document = { createElement: () => ({ click() {}, remove() {} }), body: { append() {} } };
try {
await downloadDatabaseBackup({ notify: text => toasts.push(text),
fetchUrl: async () => response(200, {}, { 'X-PromptManager-Verify': 'ok' }) });
await downloadDatabaseBackup({ notify: text => toasts.push(text),
fetchUrl: async () => response(200, {}, { 'X-PromptManager-Verify': 'Database does not contain a pair table' }) });
} finally {
globalThis.document = body;
}
assert.deepEqual(toasts, [BACKUP_DONE, backupUnverified('Database does not contain a pair table')]);
assert.equal(toasts[1], 'Database backup downloaded, but it did not pass verification: Database does not contain a pair table');
});
test('the Import dialog shares the rescan copy with Images (D15)', async () => {
const { rescanFinished, RESCAN_STARTED, RESCAN_FAILED } = await dialogs;
assert.equal(RESCAN_STARTED, 'Rescanning output folders…');
assert.equal(RESCAN_FAILED, 'Rescan could not finish. Try again.');
assert.equal(rescanFinished({ total: 1, roots: 2 }), 'Rescan finished. 1 file in 2 output folders.');
assert.equal(rescanFinished({ total: 1200, roots: 1 }), 'Rescan finished. 1,200 files in 1 output folder.');
assert.equal(rescanFinished(null), 'Rescan finished.');
});
test('applyPlan follows a started job even after its section left, and reports the outcome (S9 N5, I1)', async () => {
const { applyPlan } = await import('../../web/js/workspace/maintenance-review.mjs');
const job = { id: 'a'.repeat(32), kind: 'restore', title: 'Database restore', state: 'running' };
const tracked = [];
const store = { track: raw => { tracked.push(raw.id); return raw; }, load() {} };
const left = new AbortController();
left.abort();
const toasts = [], routes = [];
const outcome = await applyPlan({ api: { post: async () => ({ success: true, job }) }, store, scope: { signal: left.signal },
token: 't', notify: text => toasts.push(text), navigate: hash => routes.push(hash) });
// The page's store follows it (a finished restore must reload the views); the left section neither toasts nor opens it.
assert.deepEqual(tracked, [job.id]);
assert.deepEqual([toasts, routes], [[], []]);
assert.deepEqual(outcome, { job });
const busy = new ApiError(409, 'Busy', 'busy', null, { success: false, code: 'busy', running: { id: 'j1', title: 'Scan' } });
const live = { signal: new AbortController().signal };
const refused = await applyPlan({ api: { post: async () => { throw busy; } }, store, scope: live, token: 't',
notify: text => toasts.push(text), navigate: hash => routes.push(hash) });
assert.deepEqual(refused, { refused: busy });
assert.deepEqual(toasts, [['Another job is running: ', { text: 'Scan', href: '#/activity/j1' }, '.']]);
});
test('after an apply: follow a started job, reopen a busy refusal, release the token otherwise (I1)', async () => {
const { afterApply } = await backups;
const refusal = code => ({ refused: new ApiError(409, 'x', code, null, { success: false, code }) });
assert.equal(afterApply({ job: { id: 'j' } }), 'follow');
assert.equal(afterApply(refusal('busy')), 'reopen');
for (const outcome of [refusal('plan-used'), refusal('plan-expired'), refusal('plan-changed'), refusal('storage-changed'),
{ refused: new TypeError('offline') }, { lost: true }, undefined]) {
assert.equal(afterApply(outcome), 'release');
}
});
test('a restore job that hands its review back reopens it once in the page that started it; any other end forgets it (I1)', async () => {
const { rememberRestore, onRestoreHandedBack } = await backups;
let list = [];
const listeners = new Set();
const activity = { jobs: () => list, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
const emit = next => { list = next; for (const listener of listeners) listener(); };
const reopened = [];
const stop = onRestoreHandedBack(activity, review => reopened.push(review.token));
rememberRestore('kept', { token: 'k' });
rememberRestore('done', { token: 'd' });
rememberRestore('gone', { token: 'g' });
emit([{ id: 'kept', kind: 'restore', state: 'running', scope: {} }]);
emit([{ id: 'kept', kind: 'restore', state: 'failed', scope: { failure: 'busy', review_kept: true } },
{ id: 'done', kind: 'restore', state: 'finished', scope: {} },
{ id: 'gone', kind: 'restore', state: 'failed', scope: { failure: 'busy' } }]);
emit(list);
assert.deepEqual(reopened, ['k']);
stop();
assert.equal(listeners.size, 0);
});
test('small files show KB; a full review store says what to do (N3, I1)', async () => {
const { snapshotText, reviewRefusal, EXPORTS } = await backups;
assert.equal(snapshotText({ name: 'a.db', size: 40 * 1024 }), 'a.db · 40 KB');
assert.equal(snapshotText({ name: 'a.db', size: 10 }), 'a.db · 1 KB');
assert.equal(snapshotText({ name: 'a.db', size: 3 * 2 ** 20 }), 'a.db · 3.0 MB');
const full = new ApiError(429, 'Two restores are already being reviewed. Discard one first.', 'too-large', null,
{ success: false, code: 'too-large' });
assert.equal(reviewRefusal(full), 'Two restores are already being reviewed in another window. Discard one there, ' +
'or try again when it expires (15 minutes after it was reviewed).');
// N1: /export is the legacy list of every prompt.
assert.deepEqual(EXPORTS[1].slice(0, 2), ['Export all prompts JSON', '/prompt_manager/export']);
});
+7 -4
View File
@@ -34,14 +34,17 @@ test('metric 4 is the last health result', async () => {
});
test('library health rows never invent a review count, a backup or thumbnails', async () => {
const { ambiguousTitle, backupText } = await overviewModule;
const { ambiguousTitle } = await overviewModule;
// S9: the Backup status row is Backup & export's status line (system-backups.mjs, tested there).
const { backupStatusText } = await import('../../web/js/workspace/system-backups.mjs');
assert.equal(ambiguousTitle({ pending: null }), 'Ambiguous image links');
assert.equal(ambiguousTitle(undefined), 'Ambiguous image links');
assert.equal(ambiguousTitle({ pending: 1 }), '1 ambiguous image link');
assert.equal(ambiguousTitle({ pending: 3 }), '3 ambiguous image links');
assert.equal(backupText(null), 'No retained backup record.');
assert.equal(backupText({ verified_at: 'not a time' }), 'No retained backup record.');
assert.match(backupText({ verified_at: '2026-10-22T09:30:00Z' }), /^Last verified backup .+\.$/);
assert.equal(backupStatusText({ safety_backups: [], last_download: null }), 'No backup recorded.');
assert.equal(backupStatusText({ safety_backups: [{ modified_at: 'not a time' }], last_download: null }), 'No backup recorded.');
assert.match(backupStatusText({ safety_backups: [], last_download: { at: '2026-10-22T09:30:00Z', verified: true } }),
/^Downloaded backup · .+ · verified\.$/);
});
test('the thumbnail row is its own read of the thumbnail status', async () => {
+35 -1
View File
@@ -889,7 +889,7 @@ test('review dialog backup box keeps the dialog open and toasts the outcome', as
assert.equal(action.getAttribute('aria-disabled'), 'true');
// A second press while the copy downloads is ignored.
action.click();
answer({ ok: true, blob: async () => new Blob(['db']), headers: { get: () => 'attachment; filename="prompts_backup_1.db"' } });
answer({ ok: true, blob: async () => new Blob(['db']), headers: { get: name => (name === 'Content-Disposition' ? 'attachment; filename="prompts_backup_1.db"' : null) } });
await tick();
assert.deepEqual(toasts, [BACKUP_DONE]);
assert.equal(modal(dom).open, true);
@@ -933,3 +933,37 @@ test('the scan review is a thin openReviewDialog caller with its scope switch',
dom.restore();
}
});
test('a review dialog reports one dismissal for Cancel, ×, Escape, a replacing dialog and the page leaving, never after its primary (S9 M1)', async () => {
const { openReviewDialog, openContentDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
const dismissed = [];
const open = (name, primary = 'Restore') => openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']],
cancelLabel: 'Cancel', primary: { label: primary, danger: true }, onStart() {}, onDismiss: () => dismissed.push(name) });
try {
open('cancel');
button(byId(dom, 'modal-body'), 'Cancel').click();
open('close');
byId(dom, 'modal-close').click();
open('escape');
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
open('replaced');
openContentDialog({ scope, title: 'Other', nodes: [] });
closeWorkspaceDialog();
open('started');
button(byId(dom, 'modal-body'), 'Restore').click();
await tick();
open('left');
scope.dispose();
await tick();
assert.deepEqual(dismissed, ['cancel', 'close', 'escape', 'replaced', 'left']);
assert.throws(() => openReviewDialog({ scope: createScope(), title: 'x', grid: [['a', 'b']], cancelLabel: 'Cancel',
primary: { label: 'Go' }, onStart() {}, onDismiss: 'no' }), TypeError);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
+27 -1
View File
@@ -205,13 +205,39 @@ class TestPreviewLease(RestoreTestCase):
await lease.__aexit__(None, None, None)
self.assertEqual(job.state, "failed")
self.assertEqual(job.last_event["code"], "busy")
self.assertIn("review is kept", job.status_line)
# S9 I1: the page that started it reopens the kept review (Restore again
# or Discard); the flag tells it so, and the line says where the review
# is, never "start the restore again" from a place that cannot.
self.assertIs(job.scope.get("review_kept"), True)
self.assertEqual(job.status_line, backup_restore.REVIEW_KEPT)
self.assertIn("reopened in the page that started", job.status_line)
self.assertEqual(len(self.stages()), 1)
# The same review applies once the requests are done.
job = await self.finish((await self.apply(body["token"]))["job"]["id"])
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(_texts(self.db_path), ["restored"])
async def test_a_review_that_cannot_be_kept_is_not_flagged_kept(self):
self.save("live")
body = await self.preview(self.upload(["restored"]))
lease = self.api._jobs.lease("Stuck preview")
await lease.__aenter__()
try:
with (
patch.object(jobs, "LEASE_DRAIN_SECONDS", 0),
patch.object(
backup_restore.RestorePlanStore, "give_back", lambda *_: False
),
):
started = await self.apply(body["token"])
job = await self.finish(started["job"]["id"])
finally:
await lease.__aexit__(None, None, None)
self.assertEqual(job.state, "failed")
self.assertNotIn("review_kept", job.scope)
self.assertEqual(job.status_line, backup_restore.REQUESTS_BUSY)
self.assertEqual(len(self.stages()), 0)
class TestStoreOnTheLoop(RestoreTestCase):
"""M3: entries leave the store on the loop; only file removal is threaded."""
+9 -3
View File
@@ -1,7 +1,7 @@
{
"admin.js": {
"lines": 32,
"branches": 58,
"branches": 61,
"functions": 27,
"statements": 32
},
@@ -175,7 +175,7 @@
},
"workspace/activity-scan.mjs": {
"lines": 96,
"branches": 78,
"branches": 87,
"functions": 98,
"statements": 96
},
@@ -415,7 +415,7 @@
},
"workspace/maintenance-review.mjs": {
"lines": 94,
"branches": 85,
"branches": 86,
"functions": 90,
"statements": 94
},
@@ -497,6 +497,12 @@
"functions": 98,
"statements": 98
},
"workspace/system-backups.mjs": {
"lines": 95,
"branches": 88,
"functions": 94,
"statements": 95
},
"workspace/system-controls.mjs": {
"lines": 98,
"branches": 89,
+12 -1
View File
@@ -14,7 +14,8 @@
* 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; S10: `lora_import` counts Imported, Linked to existing, Skipped, Images downloaded, Download
* errors and Errors and, when it failed, ends with the server's reason (the previous imports were kept). Any other kind (the health check, a held legacy-page operation, a kind this page does not know) gets
* errors and Errors and, when it failed, ends with the server's reason (the previous imports were kept); S9: `restore`
* ends with the server's line (what was restored, or why nothing changed). 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.
*
@@ -190,6 +191,16 @@ export const KINDS = Object.freeze({
openable: groups('kept'),
log: 'Cleanup log',
}),
// System S9: the reviewed database restore (exclusive, never cancellable, indeterminate); it ends with the server's
// line: what was restored, or why nothing was changed.
restore: Object.freeze({
...GENERIC,
pillVerb: 'Restoring the database',
finishToast: job => (job.status_line && job.state !== 'cancelled'
? `${job.title} ${job.state === 'failed' ? 'failed' : 'finished'} · ${job.status_line}` : titleFinished(job)),
line: job => (job.state === 'running' ? '' : job.status_line || ''),
log: 'Restore log',
}),
// System S10: the reviewed LoRA reference import; cancellable only while it downloads (the server says when).
lora_import: Object.freeze({
...GENERIC,
+12 -4
View File
@@ -12,6 +12,10 @@ import { navigateTo } from './state.mjs';
export const BACKUP_URL = '/prompt_manager/backup';
export const BACKUP_DONE = 'Database backup downloaded.';
// S8 M5: a copy that failed a semantic check is still sent, with the reason in this header ('ok' otherwise).
export const VERIFY_HEADER = 'X-PromptManager-Verify';
/** The toast for a downloaded copy the server could not verify (S9). */
export const backupUnverified = reason => `Database backup downloaded, but it did not pass verification: ${reason}`;
export const BACKUP_FAILED = 'The database could not be backed up. Try again.';
export const REPORT_DONE = 'Job report downloaded.';
export const REPORT_FAILED = 'The report could not be downloaded. Try again.';
@@ -28,8 +32,8 @@ export function backupFilename(disposition, fallback = FALLBACK_NAME) {
return match ? match[1] : fallback;
}
/** Save a fetched response through a temporary link, named by `filename`. */
function saveResponse(blob, filename) {
/** Save a fetched response through a temporary link, named by `filename` (also System's exports, S9). */
export function saveResponse(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -40,14 +44,18 @@ function saveResponse(blob, filename) {
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** Download the prompts database copy the legacy admin page also uses; toasts the outcome. */
/**
* Download the prompts database copy the legacy admin page also uses; toasts the outcome, and says so when the server
* sent a copy it could not verify (S9).
*/
export async function downloadDatabaseBackup({ fetchUrl = window.ApiClient.fetchUrl, notify = showWorkspaceToast } = {}) {
try {
const response = await fetchUrl(BACKUP_URL);
if (!response.ok) throw new Error(`Backup failed with ${response.status}`);
const blob = await response.blob();
saveResponse(blob, backupFilename(response.headers.get('Content-Disposition')));
notify(BACKUP_DONE);
const verify = response.headers.get(VERIFY_HEADER);
notify(verify && verify !== 'ok' ? backupUnverified(verify) : BACKUP_DONE);
return true;
} catch {
notify(BACKUP_FAILED);
+5 -10
View File
@@ -12,7 +12,7 @@ import { createMediaViewer } from './media-viewer.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, rescanFinished } from './workspace-dialogs.mjs';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
@@ -70,15 +70,6 @@ function loadFailure(sourcesRetry, categoriesFailed) {
if (sourcesRetry) return `Sources and folders could not be loaded. ${sourcesRetry}`;
return categoriesFailed ? `Categories could not be loaded. ${RETRY_RESCAN}` : '';
}
// Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer
// counts every listed file across the output roots it walked.
const RESCAN_STARTED = 'Rescanning output folders…';
const RESCAN_FAILED = 'Rescan could not finish. Try again.';
function rescanFinished(result) {
const {total, roots} = result ?? {};
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
}
// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
@@ -90,6 +81,10 @@ setFavoriteVisitTarget((key, favorite) => {
// A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6).
onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; });
/** Forget the last Images visit (its listing, scroll and selection): the next visit reads everything again (S9). */
export function forgetImagesVisit() {
lastVisit = null;
}
/** The image selected in Images when it was last left (the concept's `state.selectedImage`, for Tags › Auto-tag images). */
export function selectedImage() {
const key = lastVisit?.selection;
+18 -3
View File
@@ -1,10 +1,10 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts,
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages, onFavoriteChange } from './images.mjs';
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
import { forgetLibraryVisit, mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
@@ -18,6 +18,7 @@ import { mountInspect } from './inspect.mjs';
import { mountReview } from './review.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
import { onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
import { mountLibraryBanner } from './library-location.mjs';
import { detectJxlSupport } from './thumbnail-support.mjs';
@@ -159,6 +160,20 @@ function mountWorkspace() {
if (ended) refreshCounts(REVIEW_WRITE_COUNTS);
}));
refreshCounts(NAV_COUNT_ROUTES);
// S9: a finished restore replaced the library. The cached visits are dropped, the counts and saved views read
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
scope.cleanup(onRestoreFinished(activity, () => {
forgetImagesVisit();
forgetLibraryVisit();
refreshCounts(NAV_COUNT_ROUTES);
savedViews.load();
if (currentKey !== 'activity') navigateTo(window.location.hash);
}));
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
// it opens again over the page shown now, with Restore (again) and Discard; leaving it any other way discards it.
scope.cleanup(onRestoreHandedBack(activity, review => {
if (viewScope && !viewScope.signal.aborted) showRestoreReview({ api, store: activity, scope: viewScope, ...review, again: true });
}));
let viewScope;
let firstRender = true;
// D62: a page whose teardown must wait (Inspect, while a video is still starting) holds the next route until it
+4
View File
@@ -65,6 +65,10 @@ export function invalidateLibraryAfterWrite(commit) {
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
for(const listener of [...writeListeners]) listener(commit);
}
/** Forget the shelf's last visit (filters, items and position): the next visit starts fresh (S9: a restored library). */
export function forgetLibraryVisit() {
writeGeneration++;shelfVisit=null;
}
/** Observe confirmed Library and vocabulary writes (the sidebar re-reads its counts); returns the unsubscribe. */
export function onLibraryWrite(listener) {
if(typeof listener!=='function') throw new TypeError('Invalid Library write listener');
+17 -11
View File
@@ -155,7 +155,7 @@ export function reviewRefusal(error) {
}
/** Hold `trigger` aria-disabled with `busyText` while `work()` runs; a second press is ignored. */
async function holding(trigger, busyText, work) {
export async function holding(trigger, busyText, work) {
if (trigger?.getAttribute('aria-disabled') === 'true') return;
const text = trigger?.textContent;
trigger?.setAttribute('aria-disabled', 'true');
@@ -169,9 +169,11 @@ async function holding(trigger, busyText, work) {
}
/**
* Apply a reviewed plan's `token` (at `url`: maintenance's apply, or the LoRA import's, S10); 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.
* 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 }) {
@@ -179,24 +181,28 @@ export async function applyPlan({ api, store, scope, token, reviewAgain, notify
try {
result = await api.post(url, { token });
} catch (error) {
if (scope.signal.aborted) return;
if (scope.signal.aborted) return { refused: error };
const refusal = applyRefusal(error);
notify(refusal.toast);
if (refusal.lost) store.load();
if (refusal.review) reviewAgain?.();
return;
return refusal.lost ? { lost: true } : { refused: error };
}
if (scope.signal.aborted) return;
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 {
notify(NOT_CONFIRMED);
if (!scope.signal.aborted) notify(NOT_CONFIRMED);
store.load();
return;
return { lost: true };
}
notify(`${job.title} started.`);
navigate(`#/activity/${job.id}`);
if (!scope.signal.aborted) {
notify(`${job.title} started.`);
navigate(`#/activity/${job.id}`);
}
return { job };
}
/** Show the reviewed `plan` for `operation`; Apply applies exactly this token. */
+464
View File
@@ -0,0 +1,464 @@
/**
* 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).
* I1: a Restore the server refuses as busy reopens the same review (Restore retries, Discard releases); a job that
* hands its review back (the leases did not drain) reopens it in this page (`onRestoreHandedBack`, index.mjs);
* every other path that abandons the token discards it. The picker has no type filter (M3: the safety backups have
* no `.db` extension); a file the server refuses toasts its validator's reason, and nothing is staged.
* - "Portable export": D103 "Export JSON" downloads the Library document (`/library/export`); the secondary row
* downloads every prompt (`/export`, the legacy admin 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 (its line says what it does), 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, saveResponse } from './activity-scan.mjs';
import { applyPlan, holding } 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.';
// D104 (M2): what the safety-backup policy does (the concept's "Preview a verified backup…" line misstates it).
export const SAFETY_DESCRIPTION = 'Keep a verified copy of the database on disk before maintenance, cleanups and LoRA ' +
'imports change records. A restore always keeps one.';
// I1: a third review while two are held (another window's): what the user can do about it.
export const REVIEWS_FULL = 'Two restores are already being reviewed in another window. Discard one there, or try again ' +
'when it expires (15 minutes after it was reviewed).';
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'],
// N1: the legacy admin page's Export: every prompt, as `{export_date, total_prompts, prompts}`.
['Export all prompts JSON', '/prompt_manager/export', 'prompt_manager_prompts.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(' · ');
}
/** A file size: whole KB below 1 MB (never "0.0 MB"), else MB with one decimal. */
function sizeText(bytes) {
if (bytes < 2 ** 20) return `${number(Math.max(1, Math.round(bytes / 1024)))} KB`;
return `${(bytes / 2 ** 20).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} MB`;
}
/** The Snapshot value: the chosen file's name, size and date. */
export function snapshotText(file) {
const size = Number.isFinite(file?.size) ? ` · ${sizeText(file.size)}` : '';
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?.status === 429) return REVIEWS_FULL;
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(() => {}, () => {});
}
/**
* I1: what to do with a reviewed token once Restore was pressed: `follow` the started job (the page keeps the review
* until it ends, in case the job hands it back), `reopen` the review after a busy refusal (the server keeps the token:
* Restore retries, Discard releases), and `release` it on every other path (a refusal that dropped it, a used one, a
* lost answer): a DELETE of a token the server no longer holds, or a job took, changes nothing.
*/
export function afterApply(outcome) {
if (outcome?.job) return 'follow';
if (outcome?.refused?.code === 'busy') return 'reopen';
return 'release';
}
// I1: the reviews whose restore job is running, by job id, so a job that hands its review back reopens it here.
const startedReviews = new Map();
/** Keep `review` (`{token, preview, file}`) until the restore job `jobId` ends. */
export function rememberRestore(jobId, review) {
startedReviews.set(jobId, review);
}
/**
* Call `onKept(review)` when a restore job this page started fails and the server kept its review
* (`scope.review_kept`, the leases did not drain); any other end forgets the review (the server discarded or used its
* stage). Returns the unsubscribe.
*/
export function onRestoreHandedBack(activity, onKept) {
return activity.subscribe(() => {
for (const job of activity.jobs()) {
if (job.kind !== RESTORE_KIND || job.state === 'running' || !startedReviews.has(job.id)) continue;
const review = startedReviews.get(job.id);
startedReviews.delete(job.id);
if (job.state === 'failed' && job.scope?.review_kept === true) onKept(review);
}
});
}
/**
* D105: "Restore preview" for a staged `preview` of `file`. Leaving it any way but Restore discards the stage; after a
* refusal or a handed-back job (`again`), Cancel reads "Discard". Restore applies exactly this token and acts on the
* outcome (`afterApply`).
*/
export function showRestoreReview({ api, store, scope, preview, file, trigger = null, onBackup = () => downloadDatabaseBackup(),
again = false, reviewAgain = null, notify = showWorkspaceToast, navigate = navigateTo, fetchUrl = window.ApiClient.fetchUrl }) {
const token = preview.token;
const show = (retry, shownScope = scope) => showRestoreReview({ api, store, scope: shownScope, preview, file, trigger,
onBackup, again: retry, reviewAgain, notify, navigate, fetchUrl });
openReviewDialog({ scope, trigger, title: RESTORE_TITLE, grid: restoreGrid(preview, file),
sentence: restoreNote(preview), backup: { copy: RESTORE_BACKUP_COPY, onBackup },
cancelLabel: again ? 'Discard' : 'Cancel', primary: { label: RESTORE_LABEL, danger: true },
onDismiss: () => discardRestore(token, { fetchUrl }),
onStart: async () => {
const outcome = await applyPlan({ api, store, scope, token, reviewAgain, notify, navigate, url: APPLY_URL });
const next = afterApply(outcome);
if (next === 'follow') rememberRestore(outcome.job.id, { token, preview, file });
else if (next === 'reopen' && !scope.signal.aborted) show(true);
else discardRestore(token, { fetchUrl });
} });
}
/**
* 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 reviewAgain = () => reviewRestore({ api, store, scope, file, trigger, onBackup, notify, navigate, fetchUrl });
showRestoreReview({ api, store, scope, preview, file, trigger, onBackup, reviewAgain, notify, navigate, fetchUrl });
});
}
/** 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;
saveResponse(blob, filenameOf(response, fallback));
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';
// M3: no type filter: the safety backups (`prompts.db.backup_<time>`) have no `.db` extension; the server validates.
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: SAFETY_DESCRIPTION,
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];
}
+30 -10
View File
@@ -7,6 +7,7 @@
* N issues / Incomplete); the review, backup, thumbnail and sources rows say what the server reports, and nothing
* reads 0 before the read answers: a load shows a status line, a failure an alert and Retry. The thumbnail row is
* its own read of `/images/thumbnails/status` (a walk of every source): "Checking thumbnails…" until it answers.
* S9: the Backup status row is its own read of `/backup/status`, worded as Backup & export's status line.
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
@@ -25,6 +26,7 @@ import { noun } from './plural.mjs';
import { operationRow } from './system-controls.mjs';
import { openThumbnailReview } from './thumbnail-review.mjs';
import { isLocation, locationRow, onMoveEnded } from './library-location.mjs';
import { STATUS_LOADING, STATUS_URL, backupStatusText } from './system-backups.mjs';
export const OVERVIEW_URL = '/prompt_manager/system/overview';
export const STATISTICS_URL = '/prompt_manager/system/statistics';
@@ -75,12 +77,6 @@ export function ambiguousTitle(needsReview) {
return `${number(pending)} ambiguous image ${noun(pending, 'link')}`;
}
/** D93: the backup row; only a recorded, verified backup is ever named. */
export function backupText(backup) {
if (!backup) return 'No retained backup record.';
const when = new Date(backup.verified_at ?? backup.created_at);
return Number.isNaN(when.getTime()) ? 'No retained backup record.' : `Last verified backup ${when.toLocaleString()}.`;
}
/**
* D93: the thumbnail row's title from the `/images/thumbnails/status` answer: undefined while it is read, null when
@@ -366,10 +362,12 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
const [sourcesTitle, sourcesDescription] = sourcesCopy(overview.sources);
const thumbnails = operationRow({ scope, title: THUMBNAILS_LOADING, description: 'Generate only missing previews when necessary.',
label: 'Review thumbnails', onClick: openThumbnails });
const backupRow = operationRow({ scope, title: 'Backup status', description: STATUS_LOADING, label: 'View backups',
onClick: go('backups') });
const health = surface('Library health', [
review,
operationRow({ scope, title: 'Backup status', description: backupText(overview.backup), label: 'View backups',
onClick: go('backups') }),
// S9: what GET /backup/status reports, as Backup & export's status line says it (its own read).
backupRow,
// D129: where the library lives and why; Move library when it is in the node folder.
...(isLocation(overview.location)
? [locationRow(overview.location, { api, activity, scope, notify, navigate })] : []),
@@ -385,7 +383,7 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
]);
const columns = el('div', undefined, 'two-col');
columns.append(health, preferences);
return { nodes: [metricsNode(overview), columns], thumbnails };
return { nodes: [metricsNode(overview), columns], thumbnails, backup: backupRow };
}
/**
@@ -398,7 +396,14 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
const overviewRead = latestOnly({ lifetime: scope.signal });
const statisticsRead = latestOnly({ lifetime: scope.signal });
const thumbnailsRead = latestOnly({ lifetime: scope.signal });
const backupRead = latestOnly({ lifetime: scope.signal });
let shown = [];
// The backup status (undefined while read, null when it failed), painted into its row once both are there (S9).
let backupStatus;
let backupRow = null;
const paintBackup = () => {
if (backupRow) backupRow.querySelector('p').textContent = backupStatusText(backupStatus);
};
// The thumbnail status (undefined while read, null when it failed), painted into the row whenever both are there.
let thumbnailStatus;
let thumbnailRow = null;
@@ -438,12 +443,14 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
try {
const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' }));
if (overview === STALE || scope.signal.aborted) return;
const { nodes, thumbnails } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics,
const { nodes, thumbnails, backup } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics,
openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) });
thumbnailRow = thumbnails;
backupRow = backup;
sources = overview.sources;
swap(nodes);
paintThumbnails();
paintBackup();
} catch (error) {
if (scope.signal.aborted) return;
const alert = el('p', LOAD_FAILED_TEXT);
@@ -453,6 +460,7 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
swap(loading());
load();
loadThumbnails();
loadBackup();
});
const panel = el('section', undefined, 'surface');
panel.append(alert, retry);
@@ -470,10 +478,22 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
thumbnailStatus = status ?? null;
paintThumbnails();
}
async function loadBackup() {
let status;
try {
status = await backupRead(signal => api.get(STATUS_URL, { signal, cache: 'no-store' }));
} catch (error) {
status = null;
}
if (status === STALE || scope.signal.aborted) return;
backupStatus = status ?? null;
paintBackup();
}
const first = swap(loading());
// D129: a finished Move library changes where the library lives.
scope.cleanup(onMoveEnded(activity, load));
load();
loadThumbnails();
loadBackup();
return first;
}
+5 -4
View File
@@ -5,8 +5,8 @@
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs),
* Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs), Integrations (system-integrations.mjs) and
* Maintenance (system-maintenance.mjs) are native.
* Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs), Integrations (system-integrations.mjs),
* Maintenance (system-maintenance.mjs) and Backup & export (system-backups.mjs) are native.
*
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
@@ -19,6 +19,7 @@ import { mountOverview } from './system-overview.mjs';
import { mountAppearance } from './system-appearance.mjs';
import { mountSources } from './system-sources.mjs';
import { mountMaintenance } from './system-maintenance.mjs';
import { mountBackups } from './system-backups.mjs';
import { LORA_ENABLE_URL, LORA_STATUS_URL, loraValues, mountIntegrations, writeHostInParts } from './system-integrations.mjs';
import { busyToast } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
@@ -33,7 +34,6 @@ const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existi
*/
export const SECTION_HANDOFFS = Object.freeze({
tagging: [ADMIN],
backups: [ADMIN],
diagnostics: [ADMIN],
});
export const NOT_DELIVERED = 'This setting is not available here yet.';
@@ -128,7 +128,7 @@ function markFailed(layout, store) {
/**
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11),
* Integrations (S10 Task 27) and Maintenance (S7 Task 19).
* Integrations (S10 Task 27), Maintenance (S7 Task 19) and Backup & export (S9 Task 24).
*/
export const DELIVERED_SECTIONS = Object.freeze({
overview: mountOverview,
@@ -136,6 +136,7 @@ export const DELIVERED_SECTIONS = Object.freeze({
sources: mountSources,
integrations: mountIntegrations,
maintenance: mountMaintenance,
backups: mountBackups,
});
/**
+21 -4
View File
@@ -22,6 +22,7 @@
* Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page.
*/
import { createButton, createSwitch } from '../shared/toolkit/core.js';
import { noun } from './plural.mjs';
const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags' });
const FIELD_ORDER = Object.keys(FIELD_LABELS);
@@ -255,6 +256,17 @@ function operation(title, description, label, onAction) {
const RESCAN_LABEL = 'Rescan';
const RESCAN_BUSY_LABEL = 'Rescanning…';
// Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer counts
// every listed file across the output roots it walked. Images and System › Backup & export (S9) share them.
export const RESCAN_STARTED = 'Rescanning output folders…';
export const RESCAN_FAILED = 'Rescan could not finish. Try again.';
/** The toast after a rescan answered `{total, roots}`. */
export function rescanFinished(result) {
const { total, roots } = result ?? {};
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ` +
`${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
}
/**
* "Import & inspect" with the real operations (D15). While `rescanBusy`, Rescan reads "Rescanning…" and is
@@ -415,12 +427,14 @@ function requireGrid(grid) {
* `h3` heading, the `dl.meta-grid` of `[term, value]` rows, page-built `options` nodes, the optional `p.small`
* sentence, the D55 backup box when `backup` is `{copy, onBackup}`, then `.dialog-actions` with `cancelLabel` and the
* primary (`ca-primary`, or the concept's `danger` when `primary.danger`). The primary closes the dialog, then calls
* `onStart()`. Returns `{setValue(term, text)}` for a grid value that follows an option.
* `onStart()`; `onDismiss()` runs once when the dialog closes any other way (Cancel, ×, Escape, a replacing dialog, the
* page leaving), so a staged review is discarded (S9). Returns `{setValue(term, text)}` for a grid value that follows
* an option.
*/
export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null,
cancelLabel, primary, onStart, trigger = null }) {
cancelLabel, primary, onStart, trigger = null, onDismiss = null }) {
requireScope(scope);
requireCallbacks({ onStart });
requireCallbacks({ onStart, ...(onDismiss ? { onDismiss } : {}) });
for (const [name, text] of Object.entries({ title, cancelLabel, label: primary?.label })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The review needs its ${name}`);
}
@@ -444,15 +458,18 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
if (backup) nodes.push(backupNotice(backup));
const cancel = createButton(cancelLabel);
const start = createButton(primary.label, primary.danger ? 'danger' : 'ca-primary');
let started = false;
cancel.addEventListener('click', () => finish());
start.addEventListener('click', () => {
started = true;
finish();
onStart();
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, start);
nodes.push(actions);
show(title, nodes, { scope, trigger });
// Cancel, ×, Escape, a replacing dialog or the page leaving: everything but the primary is a dismissal.
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null });
return {
setValue(term, text) {
const dd = values.get(term);
+3
View File
@@ -423,6 +423,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
/* D101: a duplicate copy's Keep label is its source and path, often long names with no space to break at; it wraps
inside its `.compare-item` so the review never scrolls sideways. */
.duplicate-groups .compare-item label { overflow-wrap: anywhere; /* D101 */ }
/* D105: a review's grid values (the restore's backup file name, a safety backup's name) may be long names with no space
to break at; they wrap inside their column, so the concept's two equal columns hold at phone width. */
#modal-body > dl.meta-grid dd { overflow-wrap: anywhere; /* D105 */ }
/* D106: Integrations' two selects keep their chosen option readable: they never shrink for the row's reason lines, and
at phone width they may grow past the concept's 120px cap (which cuts "Follow host renderer") up to 160px. */
.setting-row :is(#send-destination, #renderer) { flex-shrink: 0; /* D106 */ }