Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
367db8d7d6 | ||
|
|
447fd4668b | ||
|
|
af2d8e3792 | ||
|
|
dde8b99b75 | ||
|
|
61d5518831 | ||
|
|
9823a11492 | ||
|
|
6d4616a182 | ||
|
|
519f17bf63 |
+99
-4
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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 · .+$/);
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'))))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user