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:
@@ -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);
|
||||
});
|
||||
@@ -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,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.');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.';
|
||||
@@ -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 } 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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user