feat(system): back up, restore and export natively

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

Refs #326
This commit is contained in:
Vito Sansevero
2026-10-25 11:11:42 -07:00
parent 8252d9d226
commit 519f17bf63
21 changed files with 1195 additions and 50 deletions
+5 -2
View File
@@ -172,8 +172,8 @@ function mapOverviewConcept(reference, overview) {
// D93: the real pending count; Review metadata opens Needs review (the concept's go-review).
'3 ambiguous image links': row => ({ ...row,
title: `${number(overview.needs_review.pending)} ambiguous image link${overview.needs_review.pending === 1 ? '' : 's'}` }),
// D93: no backup has been recorded (verified backups arrive with S8).
'Backup status': row => ({ ...row, description: 'No retained backup record.' }),
// D93 (S9): what GET /backup/status reports; the fixture has no backup yet.
'Backup status': row => ({ ...row, description: 'No backup recorded.' }),
// D93: there is no paging, so the shared settings are grid and density.
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
@@ -219,6 +219,9 @@ async function productionOverview(page, theme) {
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
// The thumbnail row is its own read (I2): compare once it answered.
await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails');
// So is the backup row (S9).
await expect(page.locator('#workspace-main .operation').filter({ has: page.locator('h3', { hasText: 'Backup status' }) })
.locator('p')).toHaveText('No backup recorded.');
await page.mouse.move(0, 0);
return page.evaluate(measureSystem, '#workspace-main');
}
@@ -0,0 +1,388 @@
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' };
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+\.\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 /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 components 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 components 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 toggle = settingOf(page, 'Create a safety backup before maintenance').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);
});
+4 -1
View File
@@ -36,6 +36,9 @@ const layoutOf = page => page.locator('#workspace-main .settings-layout');
async function openOverview(page) {
await page.goto(`${workspace}#/system/overview`);
await expect(layoutOf(page).locator(':scope > .summary-metrics')).toBeVisible();
// The Backup status row is its own read of /backup/status (S9): compare once it answered.
await expect(layoutOf(page).locator('.operation').filter({ has: page.locator('h3', { hasText: 'Backup status' }) })
.locator('p')).not.toHaveText('Checking backups…');
return layoutOf(page);
}
@@ -67,7 +70,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
]);
expect(await rowsOf(layout, 'Library health')).toEqual([
['0 ambiguous image links', 'Review metadata before assigning a positive / negative pair.', 'Review metadata'],
['Backup status', 'No retained backup record.', 'View backups'],
['Backup status', 'No backup recorded.', 'View backups'],
// D129: the fixture's database is configured explicitly, so it reads as config.json's choice and does not move.
['Library location', 'browser.db · The database path set in config.json.', 'Details'],
['No missing thumbnails', 'Generate only missing previews when necessary.', 'Review thumbnails'],
+6 -4
View File
@@ -464,17 +464,19 @@ test('System is native: the concept shell, section navigation, sections, hand-of
await expect(button).toBeFocused();
// S2 Task 6: Overview is native (workspace-system.spec.mjs), and so are Appearance (S3 Task 8) and Sources & scans
// (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser), Integrations (S10
// Task 27, workspace-system-integrations.spec.mjs) and Maintenance (S7 Task 19,
// workspace-system-maintenance.spec.mjs); every other section hands off.
// Task 27, workspace-system-integrations.spec.mjs), Maintenance (S7 Task 19,
// workspace-system-maintenance.spec.mjs) and Backup & export (S9 Task 24, workspace-system-backups.spec.mjs);
// every other section hands off.
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'],
sources: ['div.notice', 'section.surface', 'div.save-row'],
integrations: ['section.surface', 'section.surface', 'div.save-row'],
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'] }[hash]
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],
backups: ['div.two-col', 'section.surface', 'div.save-row'] }[hash]
?? ['section.surface handoff', 'div.save-row']);
if (['overview', 'appearance', 'sources', 'integrations', 'maintenance'].includes(hash)) continue;
if (['overview', 'appearance', 'sources', 'integrations', 'maintenance', 'backups'].includes(hash)) continue;
const handoff = layout.locator('section.handoff');
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
+40
View File
@@ -50,6 +50,12 @@ read. These per-server hooks only change what a System test needs on top:
rows (a read-only dump) and of every media file under ``output`` and
``input`` (not the automatic previews in ``thumbnails``), so a test proves
a Cancel wrote nothing.
- ``POST /__test__/system/safety-backup`` (S9) writes one verified safety
backup beside the database, as a maintenance apply does;
``GET /__test__/system/backups`` answers ``{safety: [names], stages: n}``:
the ``<db>.backup_*`` files and the held ``.pm-restore-*`` stage folders.
Reset drops every held restore review, removes the safety backups and
forgets the session's last download.
Delays use the shared request holds (``request_hold.py``).
"""
@@ -151,11 +157,45 @@ def register_system_commands(app, routes, api, root: Path):
staged.write_bytes(config_bytes)
os.replace(staged, config_path)
def backup_files():
db_path = Path(api.db.model.db_path)
safety = sorted(
path.name
for path in db_path.parent.glob(db_path.name + ".backup_*")
if path.is_file()
)
stages = [
path
for path in db_path.parent.glob(".pm-restore-*")
if path.is_dir() and not path.name.startswith(".pm-restore-upload-")
]
return db_path, safety, stages
async def reset_backups():
await api._discard_held(api._restore_plans.pop_all())
db_path, safety, _stages = backup_files()
for name in safety:
(db_path.parent / name).unlink(missing_ok=True)
api._last_backup_download = None
@routes.post("/__test__/system/reset")
async def system_reset(request):
reset()
await reset_backups()
return web.json_response({"success": True})
@routes.post("/__test__/system/safety-backup")
async def safety_backup(request):
from py.api.maintenance import take_safety_backup
name = await api._run_in_executor(take_safety_backup, api.db.model)
return web.json_response({"success": True, "name": name})
@routes.get("/__test__/system/backups")
async def backups(request):
_db_path, safety, stages = backup_files()
return web.json_response({"safety": safety, "stages": len(stages)})
@routes.post("/__test__/system/fail")
async def system_fail(request):
data = await request.json()
+1 -1
View File
@@ -126,7 +126,7 @@ test('held legacy jobs read "(admin page)" with no outcomes', async () => {
test('unknown and generic kinds show no tiles of another kind', async () => {
const { kindOf, viewOf } = await source;
for (const kind of ['restore', '', 'constructor', '__proto__', 'health']) {
for (const kind of ['backup', '', 'constructor', '__proto__', 'health']) {
const view = kindOf(kind);
assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []], kind);
assert.deepEqual([view.log, view.cancelLabel], ['Job log', 'Cancel job']);
+1 -1
View File
@@ -344,7 +344,7 @@ test('unknown kinds get the generic view without the scan tiles', async () => {
['no_metadata', 'No usable metadata'], ['known', 'Already known']]);
assert.deepEqual([...scan.openable].sort(), ['added', 'known']);
assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']);
for (const kind of ['restore', '', 'constructor', '__proto__']) {
for (const kind of ['backup', '', 'constructor', '__proto__']) {
const view = jobView(kind);
assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [[], [], []]);
assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']);
+217
View File
@@ -0,0 +1,217 @@
// 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',
'Preview a verified backup before applying operations that change records.', '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.
for (const sample of ['Simulate backup', 'Sample backup', 'Export sample JSON', 'Production feature proposal', 'A restore would be']) {
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);
assert.equal(reviewRefusal(full), 'Two restores are already being reviewed. Discard one first.');
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 components 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.');
});
+7 -4
View File
@@ -34,14 +34,17 @@ test('metric 4 is the last health result', async () => {
});
test('library health rows never invent a review count, a backup or thumbnails', async () => {
const { ambiguousTitle, backupText } = await overviewModule;
const { ambiguousTitle } = await overviewModule;
// S9: the Backup status row is Backup & export's status line (system-backups.mjs, tested there).
const { backupStatusText } = await import('../../web/js/workspace/system-backups.mjs');
assert.equal(ambiguousTitle({ pending: null }), 'Ambiguous image links');
assert.equal(ambiguousTitle(undefined), 'Ambiguous image links');
assert.equal(ambiguousTitle({ pending: 1 }), '1 ambiguous image link');
assert.equal(ambiguousTitle({ pending: 3 }), '3 ambiguous image links');
assert.equal(backupText(null), 'No retained backup record.');
assert.equal(backupText({ verified_at: 'not a time' }), 'No retained backup record.');
assert.match(backupText({ verified_at: '2026-10-22T09:30:00Z' }), /^Last verified backup .+\.$/);
assert.equal(backupStatusText({ safety_backups: [], last_download: null }), 'No backup recorded.');
assert.equal(backupStatusText({ safety_backups: [{ modified_at: 'not a time' }], last_download: null }), 'No backup recorded.');
assert.match(backupStatusText({ safety_backups: [], last_download: { at: '2026-10-22T09:30:00Z', verified: true } }),
/^Downloaded backup · .+ · verified\.$/);
});
test('the thumbnail row is its own read of the thumbnail status', async () => {
+1 -1
View File
@@ -889,7 +889,7 @@ test('review dialog backup box keeps the dialog open and toasts the outcome', as
assert.equal(action.getAttribute('aria-disabled'), 'true');
// A second press while the copy downloads is ignored.
action.click();
answer({ ok: true, blob: async () => new Blob(['db']), headers: { get: () => 'attachment; filename="prompts_backup_1.db"' } });
answer({ ok: true, blob: async () => new Blob(['db']), headers: { get: name => (name === 'Content-Disposition' ? 'attachment; filename="prompts_backup_1.db"' : null) } });
await tick();
assert.deepEqual(toasts, [BACKUP_DONE]);
assert.equal(modal(dom).open, true);
+6
View File
@@ -503,6 +503,12 @@
"functions": 89,
"statements": 98
},
"workspace/system-backups.mjs": {
"lines": 80,
"branches": 80,
"functions": 80,
"statements": 80
},
"workspace/system-integrations.mjs": {
"lines": 96,
"branches": 88,
+12 -1
View File
@@ -14,7 +14,8 @@
* Errors (red). System S7: `maintenance` (a reviewed plan's apply) counts Changed, Protected and Errors and, when it
* failed, ends with the server's reason; `duplicates` (the search) counts Groups found; `duplicate_cleanup` counts
* Removed, Kept and Errors; S10: `lora_import` counts Imported, Linked to existing, Skipped, Images downloaded, Download
* errors and Errors and, when it failed, ends with the server's reason (the previous imports were kept). Any other kind (the health check, a held legacy-page operation, a kind this page does not know) gets
* errors and Errors and, when it failed, ends with the server's reason (the previous imports were kept); S9: `restore`
* ends with the server's line (what was restored, or why nothing changed). Any other kind (the health check, a held legacy-page operation, a kind this page does not know) gets
* the generic descriptor: title-based copy and no tiles or outcomes, so it never shows another kind's zeros (S1 review
* M6, S2 review M3). `viewOf(job)` keeps only the groups the job itself counts, so a hold (no groups) has none either.
*
@@ -190,6 +191,16 @@ export const KINDS = Object.freeze({
openable: groups('kept'),
log: 'Cleanup log',
}),
// System S9: the reviewed database restore (exclusive, never cancellable, indeterminate); it ends with the server's
// line: what was restored, or why nothing was changed.
restore: Object.freeze({
...GENERIC,
pillVerb: 'Restoring the database',
finishToast: job => (job.status_line && job.state !== 'cancelled'
? `${job.title} ${job.state === 'failed' ? 'failed' : 'finished'} · ${job.status_line}` : titleFinished(job)),
line: job => (job.state === 'running' ? '' : job.status_line || ''),
log: 'Restore log',
}),
// System S10: the reviewed LoRA reference import; cancellable only while it downloads (the server says when).
lora_import: Object.freeze({
...GENERIC,
+10 -2
View File
@@ -12,6 +12,10 @@ import { navigateTo } from './state.mjs';
export const BACKUP_URL = '/prompt_manager/backup';
export const BACKUP_DONE = 'Database backup downloaded.';
// S8 M5: a copy that failed a semantic check is still sent, with the reason in this header ('ok' otherwise).
export const VERIFY_HEADER = 'X-PromptManager-Verify';
/** The toast for a downloaded copy the server could not verify (S9). */
export const backupUnverified = reason => `Database backup downloaded, but it did not pass verification: ${reason}`;
export const BACKUP_FAILED = 'The database could not be backed up. Try again.';
export const REPORT_DONE = 'Job report downloaded.';
export const REPORT_FAILED = 'The report could not be downloaded. Try again.';
@@ -40,14 +44,18 @@ function saveResponse(blob, filename) {
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** Download the prompts database copy the legacy admin page also uses; toasts the outcome. */
/**
* Download the prompts database copy the legacy admin page also uses; toasts the outcome, and says so when the server
* sent a copy it could not verify (S9).
*/
export async function downloadDatabaseBackup({ fetchUrl = window.ApiClient.fetchUrl, notify = showWorkspaceToast } = {}) {
try {
const response = await fetchUrl(BACKUP_URL);
if (!response.ok) throw new Error(`Backup failed with ${response.status}`);
const blob = await response.blob();
saveResponse(blob, backupFilename(response.headers.get('Content-Disposition')));
notify(BACKUP_DONE);
const verify = response.headers.get(VERIFY_HEADER);
notify(verify && verify !== 'ok' ? backupUnverified(verify) : BACKUP_DONE);
return true;
} catch {
notify(BACKUP_FAILED);
+5 -10
View File
@@ -12,7 +12,7 @@ import { createMediaViewer } from './media-viewer.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, rescanFinished } from './workspace-dialogs.mjs';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
@@ -70,15 +70,6 @@ function loadFailure(sourcesRetry, categoriesFailed) {
if (sourcesRetry) return `Sources and folders could not be loaded. ${sourcesRetry}`;
return categoriesFailed ? `Categories could not be loaded. ${RETRY_RESCAN}` : '';
}
// Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer
// counts every listed file across the output roots it walked.
const RESCAN_STARTED = 'Rescanning output folders…';
const RESCAN_FAILED = 'Rescan could not finish. Try again.';
function rescanFinished(result) {
const {total, roots} = result ?? {};
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
}
// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
@@ -90,6 +81,10 @@ setFavoriteVisitTarget((key, favorite) => {
// A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6).
onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; });
/** Forget the last Images visit (its listing, scroll and selection): the next visit reads everything again (S9). */
export function forgetImagesVisit() {
lastVisit = null;
}
/** The image selected in Images when it was last left (the concept's `state.selectedImage`, for Tags › Auto-tag images). */
export function selectedImage() {
const key = lastVisit?.selection;
+13 -3
View File
@@ -1,10 +1,10 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts,
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages, onFavoriteChange } from './images.mjs';
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
import { forgetLibraryVisit, mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
@@ -18,6 +18,7 @@ import { mountInspect } from './inspect.mjs';
import { mountReview } from './review.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
import { onRestoreFinished } from './system-backups.mjs';
import { mountLibraryBanner } from './library-location.mjs';
import { detectJxlSupport } from './thumbnail-support.mjs';
@@ -159,6 +160,15 @@ 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);
}));
let viewScope;
let firstRender = true;
// D62: a page whose teardown must wait (Inspect, while a video is still starting) holds the next route until it
+4
View File
@@ -65,6 +65,10 @@ export function invalidateLibraryAfterWrite(commit) {
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
for(const listener of [...writeListeners]) listener(commit);
}
/** Forget the shelf's last visit (filters, items and position): the next visit starts fresh (S9: a restored library). */
export function forgetLibraryVisit() {
writeGeneration++;shelfVisit=null;
}
/** Observe confirmed Library and vocabulary writes (the sidebar re-reads its counts); returns the unsubscribe. */
export function onLibraryWrite(listener) {
if(typeof listener!=='function') throw new TypeError('Invalid Library write listener');
+4 -2
View File
@@ -186,15 +186,17 @@ export async function applyPlan({ api, store, scope, token, reviewAgain, notify
if (refusal.review) reviewAgain?.();
return;
}
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;
}
if (scope.signal.aborted) return;
notify(`${job.title} started.`);
navigate(`#/activity/${job.id}`);
}
+415
View File
@@ -0,0 +1,415 @@
/**
* System › Backup & export (#249 System S9 Task 24): the concept's `renderSystem` "backups" body (app.js 691–693), its
* `restore` dialog ("Restore preview", a1472–1476) and its `import` dialog (a1483–1487) on the S8 backend.
*
* - "Database backup": D103 "Back up" (the concept's "Simulate backup") downloads the verified copy of
* `GET /prompt_manager/backup` through the api client (`downloadDatabaseBackup`, the same download as every backup
* box), then reads the status again. The `p.small` under the actions is `GET /prompt_manager/backup/status` only:
* this session's last download (verified or not) and the newest safety backup on disk, else "No backup recorded."
* Overview's "Backup status" row says the same (`backupStatusText`).
* - "Review restore…" picks a `.db` file and stages it (`POST /prompt_manager/restore/preview`); D105: "Restore
* preview" shows the server's answer in the shared review (`openReviewDialog`): the concept's Snapshot / Integrity /
* Migration / Safety backup rows with real values, plus what the backup holds and what the library holds now, the
* hold line, the D55 backup box and Cancel / "Restore" (`danger`). Leaving it any way but Restore discards the stage
* (`DELETE /prompt_manager/restore/preview/<token>`). Restore starts the "Database restore" Activity job (exclusive,
* not cancellable) and opens its view; a finished restore reloads the open views (`onRestoreFinished`, index.mjs).
* A file the server refuses toasts its validator's reason; nothing is staged.
* - "Portable export": D103 "Export JSON" downloads the Library document (`/library/export`); the secondary row
* downloads the legacy components list (`/export`) and the saved pairs (`/pairs/export`). "Import / inspect file"
* opens the shared "Import & inspect" (D15, D55): Inspect a local image, Rescan and Scan for metadata.
* - D104 "Recovery policy" (the concept's "Proposed" chip kept, Q4): "Create a safety backup before maintenance" is
* the server's `safety_backup` policy, drafted behind the Save row and saved with the revision; "Scheduled database
* backups" offers Manual only, Daily and Weekly disabled with the reason; the warning note is truthful.
*
* Every request goes through the api client (or `fetchUrl` for a download or an upload); every listener sits on the
* section's `scope`.
*/
import { createButton } from '../shared/toolkit/core.js';
import { navigateTo } from './state.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { settingRow } from './system-controls.mjs';
import { busyToast, downloadDatabaseBackup, openMetadataScan } from './activity-scan.mjs';
import { applyPlan } from './maintenance-review.mjs';
import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openReviewDialog, rescanFinished } from './workspace-dialogs.mjs';
import { noun } from './plural.mjs';
export const STATUS_URL = '/prompt_manager/backup/status';
export const PREVIEW_URL = '/prompt_manager/restore/preview';
export const APPLY_URL = '/prompt_manager/restore/apply';
const SETTINGS_URL = '/prompt_manager/settings';
const RESCAN_URL = '/prompt_manager/gallery/rescan';
export const RESTORE_KIND = 'restore';
export const RESTORE_TITLE = 'Restore preview';
export const RESTORE_LABEL = 'Restore';
export const STATUS_LOADING = 'Checking backups…';
export const STATUS_FAILED = 'The backup status could not be read.';
export const NO_BACKUP = 'No backup recorded.';
export const REVIEW_FAILED = 'The backup could not be reviewed. Nothing was changed.';
export const EXPORT_FAILED = 'The export could not be downloaded. Nothing was saved to this device.';
export const SOURCES_FAILED = 'The output folders could not be read. Try again.';
export const HOST_UNAVAILABLE = 'Server settings could not be read.';
export const SCHEDULE_REASON = 'Daily and Weekly are not available yet: back up with Back up whenever you choose.';
export const WARNING_NOTE = 'Restores are staged, migrated and validated before replacing live data.';
export const RESTORE_BACKUP_COPY = Object.freeze({ heading: 'Back up the database first (recommended)',
text: 'Downloads a copy of the prompts database before the restore replaces it.' });
/** D103: the concept's Export plus the two secondary exports: `[label, url, fallback file name]`. */
export const EXPORTS = Object.freeze([
['Export JSON', '/prompt_manager/library/export', 'prompt-manager-library-v1.json'],
['Export components JSON', '/prompt_manager/export', 'prompt-manager-components.json'],
['Export saved pairs JSON', '/prompt_manager/pairs/export', 'prompt-pairs.json'],
]);
const number = value => value.toLocaleString('en-US');
const WHEN = Object.freeze({ dateStyle: 'medium', timeStyle: 'short' });
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** A server time as the reader's local date and time, or null when it is not one. */
export function whenText(value) {
const when = new Date(value);
return typeof value === 'string' && !Number.isNaN(when.getTime()) ? when.toLocaleString(undefined, WHEN) : null;
}
/**
* The status line (and Overview's Backup status) from `GET /backup/status`: undefined while it is read, null when it
* failed. Only what the server reports is named: this session's last download and the newest safety backup on disk.
*/
export function backupStatusText(status) {
if (status === undefined) return STATUS_LOADING;
if (status === null || typeof status !== 'object') return STATUS_FAILED;
const parts = [];
const download = status.last_download;
const downloaded = whenText(download?.at);
if (downloaded) parts.push(`Downloaded backup · ${downloaded} · ${download.verified === true ? 'verified' : 'not verified'}.`);
const safety = Array.isArray(status.safety_backups) ? status.safety_backups : [];
const newest = whenText(safety[0]?.modified_at);
if (newest) parts.push(`Safety backup · ${newest} · ${number(safety.length)} on disk.`);
return parts.join(' ') || NO_BACKUP;
}
/** "1,234 prompts · 56 image links · 3 saved pairs · 10 runs" for a restore summary (backup or live library). */
export function countsText(counts) {
const parts = [['prompts', 'prompt'], ['image_links', 'image link'], ['pairs', 'saved pair'], ['runs', 'run']]
.filter(([key]) => Number.isSafeInteger(counts?.[key]))
.map(([key, word]) => `${number(counts[key])} ${noun(counts[key], word)}`);
return parts.join(' · ') || 'Not reported';
}
/** The Integrity value: verified, with what the stage repaired or could not (S8's `documents_line`). */
export function integrityText(preview) {
const verified = preview?.integrity === 'ok' ? 'Verified' : 'Not verified';
const documents = preview?.documents ?? {};
const repaired = Number.isSafeInteger(documents.repaired) ? documents.repaired : 0;
const cleared = Number.isSafeInteger(documents.cleared) ? documents.cleared : 0;
const parts = [verified];
if (repaired) parts.push(`repaired ${number(repaired)} damaged workflow ${noun(repaired, 'document')}`);
if (cleared) {
const images = Number.isSafeInteger(documents.images) ? documents.images : 0;
parts.push(`could not repair ${number(cleared)} workflow ${noun(cleared, 'document')}: ${number(images)} ` +
`${noun(images, 'image')} ${images === 1 ? 'shows' : 'show'} no workflow`);
}
return parts.join(' · ');
}
/** The Snapshot value: the chosen file's name, size and date. */
export function snapshotText(file) {
const size = Number.isFinite(file?.size) ? ` · ${(file.size / 2 ** 20).toLocaleString('en-US', { minimumFractionDigits: 1,
maximumFractionDigits: 1 })} MB` : '';
const when = Number.isFinite(file?.lastModified) ? whenText(new Date(file.lastModified).toISOString()) : null;
return `${file?.name ?? 'Backup file'}${size}${when ? ` · ${when}` : ''}`;
}
/** D105: the concept's four rows with the review's values, then the two the decision needs. */
export function restoreGrid(preview, file) {
const destination = preview?.safety_backup?.destination_label;
return [
['Snapshot', snapshotText(file)],
['Integrity', integrityText(preview)],
['Migration', preview?.schema?.migrated === true ? 'Upgraded to the current schema' : 'Current schema'],
['Safety backup', `Created before replacing${destination ? ` (${destination})` : ''}`],
['In the backup', countsText(preview?.summary)],
['In the library now', countsText(preview?.live)],
];
}
/** D105: the `p.small` line: what Restore does and until when the review holds. */
export function restoreNote(preview, { now = Date.now() } = {}) {
const parts = ['Restore replaces the whole library with this backup and runs alone in Activity.'];
const expires = new Date(preview?.expires_at);
if (!Number.isNaN(expires.getTime())) {
const minutes = Math.max(1, Math.round((expires.getTime() - now) / 60000));
parts.push(`Nothing changes until you restore. This review holds for ${number(minutes)} ${noun(minutes, 'minute')}.`);
} else {
parts.push('Nothing changes until you restore.');
}
return parts.join(' ');
}
/** The toast for a review the server refused (its reason, a running job) or that could not be read. */
export function reviewRefusal(error) {
if (error?.code === 'busy') return busyToast(error.running);
if (error?.refused === true && typeof error.message === 'string' && error.message) {
if (error.code === 'invalid-backup') return `This file can't be restored: ${error.message.replace(/\.?$/, '.')}`;
return error.message;
}
return REVIEW_FAILED;
}
/** Stage `file` for review; the server's answer, or an ApiError naming its refusal. */
export async function uploadRestore(file, { fetchUrl = window.ApiClient.fetchUrl, ApiError = window.ApiClient.ApiError,
signal } = {}) {
const form = new FormData();
form.append('database_file', file, file.name);
const response = await fetchUrl(PREVIEW_URL, { method: 'POST', body: form, cache: 'no-store', credentials: 'same-origin',
signal });
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
const message = data && typeof data === 'object' ? data.error : undefined;
if (!response.ok || !data || data.success === false || typeof data.token !== 'string') {
throw new ApiError(response.status, message || `HTTP ${response.status}`, data?.code, null, data);
}
return data;
}
/** Discard a staged review; a failure only leaves it to expire on the server. */
export function discardRestore(token, { fetchUrl = window.ApiClient.fetchUrl } = {}) {
return fetchUrl(`${PREVIEW_URL}/${encodeURIComponent(token)}`, { method: 'DELETE', keepalive: true, credentials: 'same-origin' })
.then(() => {}, () => {});
}
/** Hold `trigger` aria-disabled with `busyText` while `work()` runs; a second press is ignored. */
async function holding(trigger, busyText, work) {
if (trigger?.getAttribute('aria-disabled') === 'true') return;
const text = trigger?.textContent;
trigger?.setAttribute('aria-disabled', 'true');
if (trigger) trigger.textContent = busyText;
try {
await work();
} finally {
trigger?.removeAttribute('aria-disabled');
if (trigger) trigger.textContent = text;
}
}
/**
* Stage `file`, then show "Restore preview"; Restore applies exactly that token. A review the server asks to redo (it
* expired, or the library changed) stages the same file again.
*/
export async function reviewRestore({ api, store, scope, file, trigger = null, hold = trigger, onBackup,
notify = showWorkspaceToast, navigate = navigateTo, fetchUrl = window.ApiClient.fetchUrl }) {
await holding(hold, 'Reviewing…', async () => {
let preview;
try {
preview = await uploadRestore(file, { fetchUrl, signal: scope.signal });
} catch (error) {
if (!scope.signal.aborted) notify(reviewRefusal(error));
return;
}
if (scope.signal.aborted) {
discardRestore(preview.token, { fetchUrl });
return;
}
const again = () => reviewRestore({ api, store, scope, file, trigger, onBackup, notify, navigate, fetchUrl });
openReviewDialog({ scope, trigger, title: RESTORE_TITLE, grid: restoreGrid(preview, file),
sentence: restoreNote(preview), backup: { copy: RESTORE_BACKUP_COPY, onBackup },
cancelLabel: 'Cancel', primary: { label: RESTORE_LABEL, danger: true },
onDismiss: () => discardRestore(preview.token, { fetchUrl }),
onStart: () => applyPlan({ api, store, scope, token: preview.token, reviewAgain: again, notify, navigate,
url: APPLY_URL }) });
});
}
/** The file name a download's Content-Disposition names, or `fallback`. */
function filenameOf(response, fallback) {
const match = /filename="?([^";]+)"?/i.exec(response.headers.get('Content-Disposition') || '');
return match ? match[1] : fallback;
}
/** Download one export through `fetchUrl`; toasts a failure, and nothing is saved then. */
export async function downloadExport([, url, fallback], { fetchUrl = window.ApiClient.fetchUrl, notify = showWorkspaceToast,
signal } = {}) {
try {
const response = await fetchUrl(url, { cache: 'no-store', credentials: 'same-origin', signal });
if (!response.ok) throw new Error(`Export failed with ${response.status}`);
const blob = await response.blob();
if (signal?.aborted) return false;
const href = URL.createObjectURL(blob);
const link = el('a');
link.href = href;
link.download = filenameOf(response, fallback);
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(href), 1000);
return true;
} catch {
if (!signal?.aborted) notify(EXPORT_FAILED);
return false;
}
}
/**
* Run `onFinished()` once each time a restore this page saw running ends finished (the library was replaced). A
* failed restore changed nothing. Returns the unsubscribe.
*/
export function onRestoreFinished(activity, onFinished) {
const running = new Set();
return activity.subscribe(() => {
let finished = false;
for (const job of activity.jobs()) {
if (job.kind !== RESTORE_KIND) continue;
if (job.state === 'running') running.add(job.id);
else if (running.delete(job.id) && job.state === 'finished') finished = true;
}
if (finished) onFinished();
});
}
function surface(heading, ...nodes) {
const section = el('section', undefined, 'surface');
section.append(heading, ...nodes);
return section;
}
/** The Backup & export section's mount (`mountSystem`'s `sections.backups`). */
export function mountBackups(container, { api, activity, scope, drafts, notify = showWorkspaceToast, navigate = navigateTo,
fetchUrl = window.ApiClient.fetchUrl, latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
const statusRead = latestOnly({ lifetime: scope.signal });
const status = el('p', STATUS_LOADING, 'small');
status.id = 'backup-status';
status.setAttribute('role', 'status');
async function loadStatus() {
let answer;
try {
answer = await statusRead(signal => api.get(STATUS_URL, { signal, cache: 'no-store' }));
} catch {
answer = null;
}
if (answer === STALE || scope.signal.aborted) return;
status.textContent = backupStatusText(answer);
}
const backupNow = async () => {
const done = await downloadDatabaseBackup({ fetchUrl, notify });
if (done && !scope.signal.aborted) loadStatus();
return done;
};
// Database backup: Back up, Review restore… and the status line.
const backup = createButton('Back up', 'ca-primary');
const restore = createButton('Review restore…', 'danger');
const picker = el('input');
picker.type = 'file';
picker.accept = '.db';
picker.hidden = true;
picker.id = 'restore-file';
picker.setAttribute('aria-label', 'Backup file to restore');
// Outside the concept's surface, for as long as the section is shown.
document.body.append(picker);
scope.cleanup(() => picker.remove());
scope.listen(backup, 'click', () => holding(backup, 'Backing up…', backupNow));
scope.listen(restore, 'click', () => { if (restore.getAttribute('aria-disabled') !== 'true') picker.click(); });
scope.listen(picker, 'change', () => {
const [file] = picker.files ?? [];
picker.value = '';
if (!file) return;
reviewRestore({ api, store: activity, scope, file, trigger: restore, onBackup: backupNow, notify, navigate, fetchUrl });
});
const backupActions = el('div', undefined, 'row-actions spaced');
backupActions.append(backup, restore);
const database = surface(el('h2', 'Database backup'),
el('p', 'A complete recovery snapshot, including prompt pairs, metadata and image links. Media files are separate.'),
backupActions, status);
// Portable export: Export JSON and Import / inspect file, then the two secondary exports (D103).
const exportButtons = EXPORTS.map(entry => {
const button = createButton(entry[0]);
scope.listen(button, 'click', () => holding(button, 'Exporting…',
() => downloadExport(entry, { fetchUrl, notify, signal: scope.signal })));
return button;
});
const importButton = createButton('Import / inspect file');
let rescanning = false;
let importDialog = null;
async function rescan() {
if (rescanning || scope.signal.aborted) return;
rescanning = true;
notify(RESCAN_STARTED);
try {
const result = await api.post(RESCAN_URL, {}, { signal: scope.signal });
if (!scope.signal.aborted) notify(rescanFinished(result));
} catch {
if (!scope.signal.aborted) notify(RESCAN_FAILED);
} finally {
rescanning = false;
importDialog?.setRescanBusy(false);
}
}
async function metadataScan(trigger) {
let settings;
try {
settings = await api.get(SETTINGS_URL, { signal: scope.signal, cache: 'no-store' });
} catch {
if (!scope.signal.aborted) notify(SOURCES_FAILED);
return;
}
if (scope.signal.aborted) return;
const sources = Array.isArray(settings?.sources?.effective) ? settings.sources.effective.length : 0;
openMetadataScan({ scope, store: activity, sources, trigger });
}
scope.listen(importButton, 'click', () => {
importDialog = openImportDialog({ scope, trigger: importButton, rescanBusy: rescanning,
onInspect: () => navigate('#/inspect'), onRescan: rescan, onMetadataScan: metadataScan });
});
const exportActions = el('div', undefined, 'row-actions spaced');
exportActions.append(exportButtons[0], importButton);
const moreExports = el('div', undefined, 'row-actions spaced');
moreExports.append(...exportButtons.slice(1));
const portable = surface(el('h2', 'Portable export'),
el('p', 'Export reusable recipes and tags as versioned JSON. This is distinct from a complete database backup.'),
exportActions, moreExports);
const columns = el('div', undefined, 'two-col');
columns.append(database, portable);
// Recovery policy (D104): the real safety-backup policy and the schedule the server does not have yet.
const heading = el('h2', 'Recovery policy ');
heading.append(el('span', 'Proposed', 'chip blue'));
const safety = settingRow({ id: 'backup-before', label: 'Create a safety backup before maintenance',
description: 'Preview a verified backup before applying operations that change records.',
control: { type: 'toggle' }, scope, onChange: value => drafts.set('host', 'safety_backup', value) });
const safetyInput = safety.querySelector('#backup-before');
safetyInput.dataset.key = 'safety_backup';
const schedule = settingRow({ id: 'backup-schedule', label: 'Scheduled database backups', description: SCHEDULE_REASON,
control: { type: 'select', options: ['Manual only', 'Daily', 'Weekly'], value: 'Manual only' } });
for (const option of schedule.querySelectorAll('option')) option.disabled = option.value !== 'Manual only';
const policy = surface(heading, safety, schedule, el('p', WARNING_NOTE, 'warning-note'));
let hostNote = null;
function paintPolicy() {
const value = drafts.get('host', 'safety_backup');
safetyInput.disabled = typeof value !== 'boolean';
if (typeof value === 'boolean') safetyInput.checked = value;
const unavailable = drafts.hostState?.() === 'failed' && typeof value !== 'boolean';
if (unavailable && !hostNote) {
hostNote = el('p', HOST_UNAVAILABLE, 'small');
safety.children[0].append(hostNote);
} else if (!unavailable && hostNote) {
hostNote.remove();
hostNote = null;
}
}
scope.cleanup(drafts.subscribe(paintPolicy));
paintPolicy();
loadStatus();
return [columns, policy];
}
+30 -10
View File
@@ -7,6 +7,7 @@
* N issues / Incomplete); the review, backup, thumbnail and sources rows say what the server reports, and nothing
* reads 0 before the read answers: a load shows a status line, a failure an alert and Retry. The thumbnail row is
* its own read of `/images/thumbnails/status` (a walk of every source): "Checking thumbnails…" until it answers.
* S9: the Backup status row is its own read of `/backup/status`, worded as Backup & export's status line.
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
@@ -25,6 +26,7 @@ import { noun } from './plural.mjs';
import { operationRow } from './system-controls.mjs';
import { openThumbnailReview } from './thumbnail-review.mjs';
import { isLocation, locationRow, onMoveEnded } from './library-location.mjs';
import { STATUS_LOADING, STATUS_URL, backupStatusText } from './system-backups.mjs';
export const OVERVIEW_URL = '/prompt_manager/system/overview';
export const STATISTICS_URL = '/prompt_manager/system/statistics';
@@ -75,12 +77,6 @@ export function ambiguousTitle(needsReview) {
return `${number(pending)} ambiguous image ${noun(pending, 'link')}`;
}
/** D93: the backup row; only a recorded, verified backup is ever named. */
export function backupText(backup) {
if (!backup) return 'No retained backup record.';
const when = new Date(backup.verified_at ?? backup.created_at);
return Number.isNaN(when.getTime()) ? 'No retained backup record.' : `Last verified backup ${when.toLocaleString()}.`;
}
/**
* D93: the thumbnail row's title from the `/images/thumbnails/status` answer: undefined while it is read, null when
@@ -366,10 +362,12 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
const [sourcesTitle, sourcesDescription] = sourcesCopy(overview.sources);
const thumbnails = operationRow({ scope, title: THUMBNAILS_LOADING, description: 'Generate only missing previews when necessary.',
label: 'Review thumbnails', onClick: openThumbnails });
const backupRow = operationRow({ scope, title: 'Backup status', description: STATUS_LOADING, label: 'View backups',
onClick: go('backups') });
const health = surface('Library health', [
review,
operationRow({ scope, title: 'Backup status', description: backupText(overview.backup), label: 'View backups',
onClick: go('backups') }),
// S9: what GET /backup/status reports, as Backup & export's status line says it (its own read).
backupRow,
// D129: where the library lives and why; Move library when it is in the node folder.
...(isLocation(overview.location)
? [locationRow(overview.location, { api, activity, scope, notify, navigate })] : []),
@@ -385,7 +383,7 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
]);
const columns = el('div', undefined, 'two-col');
columns.append(health, preferences);
return { nodes: [metricsNode(overview), columns], thumbnails };
return { nodes: [metricsNode(overview), columns], thumbnails, backup: backupRow };
}
/**
@@ -398,7 +396,14 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
const overviewRead = latestOnly({ lifetime: scope.signal });
const statisticsRead = latestOnly({ lifetime: scope.signal });
const thumbnailsRead = latestOnly({ lifetime: scope.signal });
const backupRead = latestOnly({ lifetime: scope.signal });
let shown = [];
// The backup status (undefined while read, null when it failed), painted into its row once both are there (S9).
let backupStatus;
let backupRow = null;
const paintBackup = () => {
if (backupRow) backupRow.querySelector('p').textContent = backupStatusText(backupStatus);
};
// The thumbnail status (undefined while read, null when it failed), painted into the row whenever both are there.
let thumbnailStatus;
let thumbnailRow = null;
@@ -438,12 +443,14 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
try {
const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' }));
if (overview === STALE || scope.signal.aborted) return;
const { nodes, thumbnails } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics,
const { nodes, thumbnails, backup } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics,
openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) });
thumbnailRow = thumbnails;
backupRow = backup;
sources = overview.sources;
swap(nodes);
paintThumbnails();
paintBackup();
} catch (error) {
if (scope.signal.aborted) return;
const alert = el('p', LOAD_FAILED_TEXT);
@@ -453,6 +460,7 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
swap(loading());
load();
loadThumbnails();
loadBackup();
});
const panel = el('section', undefined, 'surface');
panel.append(alert, retry);
@@ -470,10 +478,22 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
thumbnailStatus = status ?? null;
paintThumbnails();
}
async function loadBackup() {
let status;
try {
status = await backupRead(signal => api.get(STATUS_URL, { signal, cache: 'no-store' }));
} catch (error) {
status = null;
}
if (status === STALE || scope.signal.aborted) return;
backupStatus = status ?? null;
paintBackup();
}
const first = swap(loading());
// D129: a finished Move library changes where the library lives.
scope.cleanup(onMoveEnded(activity, load));
load();
loadThumbnails();
loadBackup();
return first;
}
+5 -4
View File
@@ -5,8 +5,8 @@
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs),
* Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs), Integrations (system-integrations.mjs) and
* Maintenance (system-maintenance.mjs) are native.
* Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs), Integrations (system-integrations.mjs),
* Maintenance (system-maintenance.mjs) and Backup & export (system-backups.mjs) are native.
*
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
@@ -19,6 +19,7 @@ import { mountOverview } from './system-overview.mjs';
import { mountAppearance } from './system-appearance.mjs';
import { mountSources } from './system-sources.mjs';
import { mountMaintenance } from './system-maintenance.mjs';
import { mountBackups } from './system-backups.mjs';
import { LORA_ENABLE_URL, LORA_STATUS_URL, loraValues, mountIntegrations, writeHostInParts } from './system-integrations.mjs';
import { busyToast } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
@@ -33,7 +34,6 @@ const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existi
*/
export const SECTION_HANDOFFS = Object.freeze({
tagging: [ADMIN],
backups: [ADMIN],
diagnostics: [ADMIN],
});
export const NOT_DELIVERED = 'This setting is not available here yet.';
@@ -128,7 +128,7 @@ function markFailed(layout, store) {
/**
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11),
* Integrations (S10 Task 27) and Maintenance (S7 Task 19).
* Integrations (S10 Task 27), Maintenance (S7 Task 19) and Backup & export (S9 Task 24).
*/
export const DELIVERED_SECTIONS = Object.freeze({
overview: mountOverview,
@@ -136,6 +136,7 @@ export const DELIVERED_SECTIONS = Object.freeze({
sources: mountSources,
integrations: mountIntegrations,
maintenance: mountMaintenance,
backups: mountBackups,
});
/**
+21 -4
View File
@@ -22,6 +22,7 @@
* Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page.
*/
import { createButton, createSwitch } from '../shared/toolkit/core.js';
import { noun } from './plural.mjs';
const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags' });
const FIELD_ORDER = Object.keys(FIELD_LABELS);
@@ -255,6 +256,17 @@ function operation(title, description, label, onAction) {
const RESCAN_LABEL = 'Rescan';
const RESCAN_BUSY_LABEL = 'Rescanning…';
// Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer counts
// every listed file across the output roots it walked. Images and System › Backup & export (S9) share them.
export const RESCAN_STARTED = 'Rescanning output folders…';
export const RESCAN_FAILED = 'Rescan could not finish. Try again.';
/** The toast after a rescan answered `{total, roots}`. */
export function rescanFinished(result) {
const { total, roots } = result ?? {};
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ` +
`${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
}
/**
* "Import & inspect" with the real operations (D15). While `rescanBusy`, Rescan reads "Rescanning…" and is
@@ -415,12 +427,14 @@ function requireGrid(grid) {
* `h3` heading, the `dl.meta-grid` of `[term, value]` rows, page-built `options` nodes, the optional `p.small`
* sentence, the D55 backup box when `backup` is `{copy, onBackup}`, then `.dialog-actions` with `cancelLabel` and the
* primary (`ca-primary`, or the concept's `danger` when `primary.danger`). The primary closes the dialog, then calls
* `onStart()`. Returns `{setValue(term, text)}` for a grid value that follows an option.
* `onStart()`; `onDismiss()` runs once when the dialog closes any other way (Cancel, ×, Escape, a replacing dialog, the
* page leaving), so a staged review is discarded (S9). Returns `{setValue(term, text)}` for a grid value that follows
* an option.
*/
export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null,
cancelLabel, primary, onStart, trigger = null }) {
cancelLabel, primary, onStart, trigger = null, onDismiss = null }) {
requireScope(scope);
requireCallbacks({ onStart });
requireCallbacks({ onStart, ...(onDismiss ? { onDismiss } : {}) });
for (const [name, text] of Object.entries({ title, cancelLabel, label: primary?.label })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The review needs its ${name}`);
}
@@ -444,15 +458,18 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
if (backup) nodes.push(backupNotice(backup));
const cancel = createButton(cancelLabel);
const start = createButton(primary.label, primary.danger ? 'danger' : 'ca-primary');
let started = false;
cancel.addEventListener('click', () => finish());
start.addEventListener('click', () => {
started = true;
finish();
onStart();
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, start);
nodes.push(actions);
show(title, nodes, { scope, trigger });
// Cancel, ×, Escape, a replacing dialog or the page leaving: everything but the primary is a dismissal.
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null });
return {
setValue(term, text) {
const dd = values.get(term);