diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index ce2196f3..ccfec488 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -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'); } diff --git a/tests/browser/workspace-system-backups.spec.mjs b/tests/browser/workspace-system-backups.spec.mjs new file mode 100644 index 00000000..9d92de76 --- /dev/null +++ b/tests/browser/workspace-system-backups.spec.mjs @@ -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); +}); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index ac76d0c3..31391260 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -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'], diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index d94afddb..50a394df 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -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')))) diff --git a/tests/browser/workspace_system_fixture.py b/tests/browser/workspace_system_fixture.py index 4cdb80ba..0fc36d97 100644 --- a/tests/browser/workspace_system_fixture.py +++ b/tests/browser/workspace_system_fixture.py @@ -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 ``.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() diff --git a/tests/js/workspace-activity-kinds.test.js b/tests/js/workspace-activity-kinds.test.js index dd712620..84831a61 100644 --- a/tests/js/workspace-activity-kinds.test.js +++ b/tests/js/workspace-activity-kinds.test.js @@ -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']); diff --git a/tests/js/workspace-activity-store.test.js b/tests/js/workspace-activity-store.test.js index 33a69d65..b4083a02 100644 --- a/tests/js/workspace-activity-store.test.js +++ b/tests/js/workspace-activity-store.test.js @@ -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']); diff --git a/tests/js/workspace-system-backups.test.js b/tests/js/workspace-system-backups.test.js new file mode 100644 index 00000000..d2f78cae --- /dev/null +++ b/tests/js/workspace-system-backups.test.js @@ -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_' }, +}); + +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_)'], + ['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.'); +}); diff --git a/tests/js/workspace-system-overview.test.js b/tests/js/workspace-system-overview.test.js index 442fecef..e4f63566 100644 --- a/tests/js/workspace-system-overview.test.js +++ b/tests/js/workspace-system-overview.test.js @@ -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 () => { diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index 052bc0be..0c2ff0e6 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -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); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 09d8f8b4..8cf7387b 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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, diff --git a/web/js/workspace/activity-kinds.mjs b/web/js/workspace/activity-kinds.mjs index 49659fdb..4bed30c7 100644 --- a/web/js/workspace/activity-kinds.mjs +++ b/web/js/workspace/activity-kinds.mjs @@ -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, diff --git a/web/js/workspace/activity-scan.mjs b/web/js/workspace/activity-scan.mjs index f92af7bd..1cb1a3a9 100644 --- a/web/js/workspace/activity-scan.mjs +++ b/web/js/workspace/activity-scan.mjs @@ -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); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index c52bab62..df8e8bd2 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -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; diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index d4fa854b..981b7ce0 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -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 diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 382d7b06..1a263ce5 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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'); diff --git a/web/js/workspace/maintenance-review.mjs b/web/js/workspace/maintenance-review.mjs index c2bbb2b8..756af089 100644 --- a/web/js/workspace/maintenance-review.mjs +++ b/web/js/workspace/maintenance-review.mjs @@ -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}`); } diff --git a/web/js/workspace/system-backups.mjs b/web/js/workspace/system-backups.mjs new file mode 100644 index 00000000..011bb95f --- /dev/null +++ b/web/js/workspace/system-backups.mjs @@ -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/`). 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]; +} diff --git a/web/js/workspace/system-overview.mjs b/web/js/workspace/system-overview.mjs index bb6d9725..26c76597 100644 --- a/web/js/workspace/system-overview.mjs +++ b/web/js/workspace/system-overview.mjs @@ -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; } diff --git a/web/js/workspace/system.mjs b/web/js/workspace/system.mjs index ccbf1e5f..08ebdd10 100644 --- a/web/js/workspace/system.mjs +++ b/web/js/workspace/system.mjs @@ -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, }); /** diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index da53853e..610fda41 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -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);