diff --git a/TESTING.md b/TESTING.md index 56f6e63f..77079de4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2705,7 +2705,8 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged `-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no reader can keep it byte-identical (`test_health_check_never_writes`). -Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–D114; D121–D134 assigned): +Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107, D109–D110 and D111–D114; D121–D134 assigned). D94, +the per-section hand-off panel, was retired by S12 (#329): every section is native. - **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server @@ -2737,10 +2738,6 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111– "N output sources" with "Monitoring and enrichment share one source list.", prefixed by "M of N configured sources available." or "None configured is available; ComfyUI's output folder is used." when that is the case. -- **D94** (all sections): a section not delivered yet shows the hand-off panel "Your - current tools are ready" with a link to each legacy tool that still owns it (the - plan's legacy coverage table); retired section by section, gone after S12. Since S11 - (#328) only Diagnostics & logs hands off (`SECTION_HANDOFFS`, to the admin page). - **D95** (health report): the concept's `health-check` dialog titled "Health report", with no sample note. It lists every check in the server's order: Integrity check, Foreign keys & pair references, Canonical hashes, Pair uniqueness (added), Missing @@ -2771,7 +2768,7 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111– - **D123** (Maintenance): the concept's Maintenance notice ("Check first. Review changes. Then apply." + Run health checks) was delivered ahead of the rest of Maintenance (S2), as the native place that runs the health check; since S7 - the section's two surfaces follow it and its D94 hand-off is gone. A press shows "Checking…" (`aria-disabled`) and opens the report; a + the section's two surfaces follow it and its hand-off panel is gone. A press shows "Checking…" (`aria-disabled`) and opens the report; a failed check toasts "The health check could not finish. Nothing was changed." - **D97** (Appearance): added rows for the legacy gallery settings the concept leaves out, after its own rows: "Show image details on cards" (`showImageInfo`; off hides a @@ -2892,6 +2889,11 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111– and the media hashes, `GET /__test__/system/digest`). `plan dialogs match the concept` (system-maintenance-concept.spec, both themes and sizes) compares each of the four operations' dialogs with the concept's, mapped by these values only. + S12 (#329): the `rotated_logs` plan (Diagnostics & logs › Review log cleanup, `h3` + "Rotated log copies") changes no library record: its Backup reads "Not needed: no + library record changes" (`backup.policy: "none"`), it has no D55 backup box, and its + `details` reads "Listed files (N)" (`a plan without a database change reads Not needed + and shows no backup box`, `rotated_logs details read Listed files`). - **D101** (Maintenance, S7): Duplicate image files → Find duplicates starts the duplicate search (Activity, D111); its job view adds "Review groups" after Download report once it finished with groups. The concept's `duplicates` dialog ("Review @@ -3183,6 +3185,55 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111– Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and the batch tests of workspace-system-tagging.spec; the mutation check renames each grid label in turn. +- **D109** (Logs, S12, #329): the concept's Logs surface on the live server. The toolbar + keeps "Level", Refresh and the download (renamed "Download log": the active log, + `/logs/download/prompt_manager.log`, through the api client), with all six minimum + levels (All levels, DEBUG, INFO, WARNING, ERROR, CRITICAL; the concept has three) and an + added `label` "Lines" + `input#log-limit[type=number]` 1–500 (default 100): a value that + is not a whole number 1–500 after trimming gets `aria-invalid` and sends nothing, a + Level change included. The `pre` shows the in-memory buffer newest first as `HH:MM:SS + LEVEL message` ("No log entries at this level." / "The log could not be read."); a + newer read wins. Added after it, `p.small#log-stats[role=status]` "Log statistics": + "N entries in memory · E errors · W warnings · F files · S on disk" (errors are ERROR + + CRITICAL, singular for 1, links not counted). "Runtime log level" adds CRITICAL after + the concept's INFO, WARNING, DEBUG, ERROR; it and "Console logging" are runtime drafts + saved by Save preferences (`POST /logs/config`, only the changed keys) and read the + server's values; their descriptions say "Applied until the server restarts." (the + concept's say a gallery toggle is replaced and output stays quiet). Inside "Log files & + retention", after the concept's line ("prompt_manager.log · · rotated copies: + N", N the `rotated` rows) and Review log cleanup (D100's `rotated_logs` plan): a + `p.small` "Rotates at M MB · keeps K rotated copies · file logging on|off · memory + buffer B entries" (read-only), an `.operation-list` of `/logs/files` (`h3` name, `p` + " · · Active log | Rotated copy | Other file | Link, not + downloaded", "Download" named "Download " except for a link), and the danger + `.operation` "Clear all logs" / "Empty the active log and delete its rotated copies." / + "Clear all logs…", which confirms ("Empties prompt_manager.log, deletes N rotated + copies (S) and clears the lines shown here. The other files in the log folder are + kept. This cannot be undone."), runs `POST /logs/truncate`, toasts "Logs cleared · N + rotated copies deleted." (plus " · K could not be deleted") and reads the viewer, + statistics, files and configuration again. Pinned by workspace-system-diagnostics.spec's + Task 5 and Task 6 tests. +- **D110** (Diagnostics, S12, #329): the concept's Diagnostics surface with live values. + The `div.meta-grid` `dd`s read one `GET /system/diagnostics`: Database "Readable · + · N images" or "Not readable: "; Sources "N sources · M watching", "N + sources · K not available", or with none configured "ComfyUI output folder · watching + | manual scan"; Viewer.js " · pinned vendor". Host renderers is D106's + renderer line from the send bridge. Each reads "Loading…" first, "Busy: is + running." when an exclusive job refuses the read (D57's naming) and "Could not be + read." on any other failure. Run checks opens the shared Health report (D95). The + buttons drop the concept's "sample": "Download diagnostic report" saves a fresh report + plus `host_renderers` as `prompt-manager-diagnostics-YYYYMMDD-HHMM.json` (local time) + and toasts "Diagnostic report downloaded." ("Busy: is running. Try again when it + ends." / "The diagnostic report could not be downloaded. Nothing was saved to this + device."); "Inspect" opens `#/inspect`, which reads a file and writes nothing. An added + `.operation` "Test image link" / "Write a placeholder image record to check that + linking works." / "Create test link…" keeps the legacy synthetic link (S12-11): it + reads the most recent prompt on mount (`aria-disabled` with "Add a prompt first." or + "The most recent prompt could not be read." added to the description), confirms in + "Create test link" naming the prompt and how Missing-file links removes the record, + and toasts "Test link created (image record N)." or "The test link could not be + created. Nothing was changed.". Pinned by workspace-system-diagnostics.spec's Task 4 + tests. - **D124** (inspector): with "Load metadata on selection" off, the Images panel and the viewer's details show "Metadata loads when you ask for it." and a "Load metadata" button instead of reading the file. The concept has no such state, so no concept @@ -3788,6 +3839,54 @@ it. Every workspace-system-tagging test asserts no `/autotag/download/` request. Changing the routes or `py/config.py` needs a ComfyUI restart; the section itself is HTML, CSS and JS only. +### Logs and diagnostics contract (#249 System S12) + +System › Diagnostics & logs (`web/js/workspace/system-diagnostics.mjs`, D109–D110) +reads and deletes only through the routes below; the legacy admin Logs and Diagnostics +modals use the same routes and keep working. + +- **Rule R** (`utils/logging_config.py`): a file may be deleted as a rotated copy only + when it is a direct entry of the log folder named `prompt_manager.log.` (`` a + decimal integer ≥ 1 with no leading zero, compared after `normcase`) and a regular + file that is not a link or junction (`lstat`). Every other entry is `active`, `link` + or `other` and is never deleted. `/logs/files` rows carry that `kind`; links are listed + with their own `lstat` size and are not counted or sized by `/logs/stats`. A download + is served only for a regular, non-link file of the folder (a link, a folder or a name + not listed is 404), read off the event loop. +- **Reviewed cleanup**: `POST /maintenance/preview {operation: "rotated_logs", options: + {}}` lists every rotated copy (basenames, size and time) and writes nothing; `POST + /maintenance/apply {token}` starts the `maintenance` job "Rotated log copies" + (admission `read`, not cancellable). Under the manager's files lock and every file + handler's lock it re-scans; when any listed file is missing, no longer rotated or + differs in size, `mtime_ns` or a non-zero inode, it deletes nothing and fails + `plan-changed` ("The log files changed since the review; nothing was deleted. Review + again.", Activity offers Review again). Otherwise it deletes exactly the listed files; + each is a Changed row ("Deleted · "), a file that cannot be removed an Errors + row. Two Prompt Manager processes sharing one log folder are not locked against each + other: each file's identity is checked just before its unlink. +- **Clear all logs** (`POST /logs/truncate`, also the admin page's Clear) empties the + active log through its handler, deletes the rotated copies (Rule R) and clears the + buffer; other files and links are kept. Response keys are unchanged. +- **Runtime settings** (`GET/POST /logs/config`): the level (DEBUG…CRITICAL) and console + logging apply until the server restarts; the retention values are shown read-only in + the workspace and the API keeps accepting them. +- **Report** (`GET /system/diagnostics`): built on the server from an explicit list of + fields (version, Python, platform, Viewer.js version, the four legacy checks, database + info, source counts, log configuration and statistics, the session's health summary + and the non-path configuration with `has_civitai_api_key`). Every path is relative to + a ComfyUI anchor or reads "outside ComfyUI"; a backstop scrub replaces any absolute + path or the saved key and counts it in `redacted`, which the tests require to be 0. It + takes a registry lease (409 `busy` while an exclusive job such as a restore runs). +- **Compressed downloads**: the gzip middleware drops a handler's declared + `Content-Length` before it compresses a body, so the response declares the compressed + length. A stale length made a browser wait for bytes that never came: the log download + of any file over 1 KB hung (`test_compressed_body_declares_its_own_length`). + +The browser server writes its logs into `/logs` +(`tests/browser/workspace_diagnostics_fixture.py`), whose hooks seed records with fixed +times, rotated copies, a link (POSIX) and a user's `.bak`, and roll the copies over; no +test reads or deletes a real log folder. + ### Browser cache ownership (#222) The legacy gallery's Clear cache used to delete every localStorage key starting `pm_` or diff --git a/py/api/__init__.py b/py/api/__init__.py index ac839fc5..d45f2c47 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -295,6 +295,10 @@ async def _gzip_middleware(request, handler): if len(compressed) >= len(body): return response + # A length the handler declared is the uncompressed one: drop it so the + # compressed body's own length is sent (#329: a stale length made the + # browser wait for bytes that never came). + response.headers.pop("Content-Length", None) response.body = compressed response.headers["Content-Encoding"] = "gzip" response.headers["Vary"] = "Accept-Encoding" diff --git a/tests/browser/server.py b/tests/browser/server.py index 95f32ce2..71ea5669 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -47,6 +47,10 @@ from tests.browser.workspace_library_editor_fixture import ( from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands from tests.browser.workspace_tagging_fixture import register_tagging_commands from tests.browser.workspace_review_fixture import register_review_commands +from tests.browser.workspace_diagnostics_fixture import ( + register_diagnostics_commands, + repoint_logs, +) CANONICAL_ROOT = Path(__file__).resolve().parents[2] CANONICAL_ASSETS = { @@ -201,6 +205,8 @@ def create_app(root: Path) -> web.Application: directory.mkdir() setattr(folder_paths, f"get_{name}_directory", lambda path=directory: str(path)) sys.modules["folder_paths"] = folder_paths + # S12: the logger writes into this server's root before any record is written. + repoint_logs(root) from py.config import GalleryConfig, PromptManagerConfig @@ -225,6 +231,7 @@ def create_app(root: Path) -> web.Application: register_activity_commands(app, routes, api) register_system_commands(app, routes, api, root) register_integrations_commands(app, routes, api, root) + reset_logs = register_diagnostics_commands(app, routes, api, root) request_holds = register_request_holds(app, routes) slow_reset_watch.install(app, api) # Resets seed a scratch copy and publish it in one commit (#384). @@ -425,6 +432,7 @@ def create_app(root: Path) -> web.Application: # No preview queued by the last test may land in the new fixture. await api._run_in_executor(api.close_auto_thumbnails) library_editor_commands.reset() + await api._run_in_executor(reset_logs) data = await request.json() if request.can_read_body else {} if not isinstance(data, dict): raise web.HTTPBadRequest() diff --git a/tests/browser/workspace-system-diagnostics.spec.mjs b/tests/browser/workspace-system-diagnostics.spec.mjs new file mode 100644 index 00000000..c0bcb963 --- /dev/null +++ b/tests/browser/workspace-system-diagnostics.spec.mjs @@ -0,0 +1,590 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; +import { seedConcept } from './concept-inventory.mjs'; + +// System › Diagnostics & logs (#249 System S12, #329) on the concept-shaped fixture, with the logger writing into this +// server's own root (workspace_diagnostics_fixture.py): the meta-grid's live values, Run checks, the report, Inspect and +// the test link (D110); the log viewer, statistics, downloads and runtime drafts (D109); and the reviewed rotated-log +// cleanup (D100) and Clear all logs, which delete only inside that temporary log folder. +const workspace = '/prompt_manager/workspace'; +const REPORT_URL = '/prompt_manager/system/diagnostics'; +const LOGS_URL = '/prompt_manager/logs'; +const CONFIG_URL = '/prompt_manager/logs/config'; +const PREVIEW_URL = '/prompt_manager/maintenance/preview'; +const APPLY_URL = '/prompt_manager/maintenance/apply'; +const TEST_LINK_URL = '/prompt_manager/diagnostics/test-link'; +const CHANGED = 'The log files changed since the review; nothing was deleted. Review again.'; +const KEY = 'sk-s12-browser-SECRET-0123456789'; +const LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']; + +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__/integrations/reset')).toBeOK(); + await expect(await request.post('/__test__/jobs/restart')).toBeOK(); + await expect(await request.post('/__test__/logs/reset')).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__/logs/reset'); + await request.post('/__test__/integrations/reset'); + 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 surfaceOf = (page, title) => layoutOf(page).locator(':scope > section.surface').filter({ + has: page.getByRole('heading', { level: 2, name: title, exact: true }) }); +const cell = (page, term) => surfaceOf(page, 'Diagnostics').locator('.meta-grid > div').filter({ + has: page.locator(`dt:text-is("${term}")`) }).locator('dd'); +const lines = page => page.locator('#log-lines > code'); +const operation = (page, title) => layoutOf(page).locator('.operation').filter({ has: page.locator(`h3:text-is("${title}")`) }); +const settingOf = (page, label) => layoutOf(page).locator('.setting-row').filter({ has: page.locator(`label:text-is("${label}")`) }); +const status = page => page.locator('#settings-status'); +const save = page => page.getByRole('button', { name: 'Save preferences', exact: true }); + +async function serverJson(request, url) { + const response = await request.get(url); + await expect(response).toBeOK(); + return response.json(); +} + +async function seedLogs(request, data = {}) { + await expect(await request.post('/__test__/logs/seed', { data })).toBeOK(); +} + +const names = async request => (await serverJson(request, '/__test__/logs/state')).entries.map(entry => entry.name); + +async function openDiagnostics(page) { + if (page.url().endsWith('#/system/diagnostics')) await page.reload(); + else await page.goto(`${workspace}#/system/diagnostics`); + await expect(layoutOf(page).locator(':scope > section.surface')).toHaveCount(2); + await expect(lines(page)).not.toHaveText('Loading…'); + await expect(cell(page, 'Database')).not.toHaveText('Loading…'); + // The renderer line settles first: its text can move the rows below. + await expect(cell(page, 'Host renderers')).not.toHaveText('Loading…'); + await expect(page.locator('#log-stats')).not.toHaveText('Loading…'); + return layoutOf(page); +} + +async function openFiles(page) { + const details = surfaceOf(page, 'Logs').locator('details'); + if (!(await details.evaluate(node => node.open))) await details.locator('summary').click(); + await expect(details.locator(':scope > p.small').first()).not.toHaveText('Loading…'); + return details; +} + +/** The levels of the viewer's lines, in order. */ +const shownLevels = async page => (await lines(page).textContent()).split('\n').filter(Boolean) + .map(line => line.split(/ +/)[1]); + +async function ended(request, id) { + await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).not.toBe('running'); + return (await serverJson(request, `/prompt_manager/jobs/${id}`)).job; +} + +/** A second page of this origin that serves a fake ComfyUI window on the send bridge. */ +async function fakeComfyWindow(context, renderer) { + const host = await context.newPage(); + await host.goto(`${workspace}#/overview`); + await host.evaluate(async drawn => { + const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js'); + window.fakeHost = createBridgeHost({ namespace: '/', origin: location.origin, label: 'PromptManager — ComfyUI', + adapter: { listTargets: () => [{ id: '7', label: 'Prompt Manager', type: 'PromptManager', roles: ['positive'], + renderer: drawn }], preview() { throw new Error('unused'); }, apply() { throw new Error('unused'); } } }); + }, renderer); + return host; +} + +async function readBlob(download) { + const chunks = await (await download.createReadStream()).toArray(); + return Buffer.concat(chunks).toString('utf8'); +} + +// ── Task 4: the Diagnostics surface ───────────────────────────────────────────────────────────────────────────────── + +test('meta-grid shows real database and source status, the pinned Viewer.js version and the host renderers', + async ({ page, request, context }) => { + const mutations = []; + page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); }); + await openDiagnostics(page); + const report = await serverJson(request, REPORT_URL); + const surface = surfaceOf(page, 'Diagnostics'); + await expect(surface.locator(':scope > .panel-heading > h2')).toHaveText('Diagnostics'); + await expect(surface.locator(':scope > .panel-heading > button')).toHaveText('Run checks'); + await expect(surface.locator('.meta-grid dt')).toHaveText(['Database', 'Sources', 'Viewer.js', 'Host renderers']); + const images = report.checks.database.image_count; + await expect(cell(page, 'Database')).toHaveText(new RegExp(`^Readable · [\\d.]+ (bytes|KB|MB|GB) · ${images.toLocaleString('en-US')} images?$`)); + // The size is the database's real size (`database_size_bytes`), never a missing key read as 0. + expect(report.database_info.database_size_bytes).toBeGreaterThan(0); + await expect(cell(page, 'Database')).not.toContainText('· 0 bytes ·'); + const { configured, watching } = report.sources; + await expect(cell(page, 'Sources')).toHaveText(configured + ? `${configured} source${configured === 1 ? '' : 's'} · ${watching} watching` + : `ComfyUI output folder · ${watching ? 'watching' : 'manual scan'}`); + await expect(cell(page, 'Viewer.js')).toHaveText('1.15.2 · pinned vendor'); + await expect(cell(page, 'Host renderers')).toHaveText('No ComfyUI window is open in this browser, so its renderer is not known.'); + // A ComfyUI window with a Classic node: the line Integrations shows (D106). + const host = await fakeComfyWindow(context, 'classic'); + await openDiagnostics(page); + await expect(cell(page, 'Host renderers')).toHaveText('Detected: Classic (1 ComfyUI window).'); + await host.close(); + // A refused read names the running job (D57's naming). + await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 409, contentType: 'application/json', + body: JSON.stringify({ success: false, code: 'busy', error: 'busy', running: { id: 'a'.repeat(32), title: 'Restore' } }) })); + await openDiagnostics(page); + await expect(cell(page, 'Database')).toHaveText('Busy: Restore is running.'); + await page.unroute(`**${REPORT_URL}`); + await expect(await request.post('/__test__/system/fail', { data: { path: REPORT_URL, times: 1 } })).toBeOK(); + await openDiagnostics(page); + await expect(cell(page, 'Sources')).toHaveText('Could not be read.'); + expect(mutations).toEqual([]); + }); + +test('Run checks opens the shared Health report', async ({ page, request }) => { + const before = await serverJson(request, '/__test__/system/digest'); + await openDiagnostics(page); + const run = surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Run checks', exact: true }); + await run.click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Health report'); + await expect(run).toHaveText('Run checks'); + await dialog(page).getByRole('button', { name: 'Close', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + await expect(run).toBeFocused(); + // The same report Overview reads: Overview's metric names it. + expect((await serverJson(request, '/prompt_manager/system/health')).report).toBeTruthy(); + expect((await serverJson(request, '/__test__/system/digest')).rows).toEqual(before.rows); +}); + +test('report download goes through the api client and holds no secret or absolute path', async ({ page, request }) => { + const { revision } = await serverJson(request, '/prompt_manager/settings'); + await expect(await request.post('/prompt_manager/lora/enable', { data: { enabled: false, trigger_words_enabled: false, path: '', + civitai_api_key: KEY, expected_revision: revision } })).toBeOK(); + await openDiagnostics(page); + const reads = []; + page.on('request', item => { if (item.url().endsWith(REPORT_URL)) reads.push(item.url()); }); + const download = page.waitForEvent('download'); + await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click(); + const file = await download; + expect(file.suggestedFilename()).toMatch(/^prompt-manager-diagnostics-\d{8}-\d{4}\.json$/); + await expect(toast(page)).toHaveText('Diagnostic report downloaded.'); + expect(reads).toHaveLength(1); + const text = await readBlob(file); + const report = JSON.parse(text); + expect(report.host_renderers).toBe('No ComfyUI window is open in this browser, so its renderer is not known.'); + expect(report.config.integrations.has_civitai_api_key).toBe(true); + expect(report.redacted).toBe(0); + expect(report.viewerjs).toBe('1.15.2'); + expect(Object.keys(report.checks)).toEqual(['database', 'dependencies', 'comfyui_output', 'image_monitor']); + expect(text).not.toContain(KEY); + expect(text).not.toContain('pm-browser-'); + const strings = []; + const walk = value => { + if (typeof value === 'string') strings.push(value); + else if (value && typeof value === 'object') Object.values(value).forEach(walk); + }; + walk(report); + expect(strings.filter(value => /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(value))).toEqual([]); + // A refused report names the job and saves nothing. + await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 409, contentType: 'application/json', + body: JSON.stringify({ success: false, code: 'busy', error: 'busy', running: { id: 'a'.repeat(32), title: 'Restore' } }) })); + let saved = false; + page.on('download', () => { saved = true; }); + await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click(); + await expect(toast(page)).toHaveText('Busy: Restore is running. Try again when it ends.'); + await page.unroute(`**${REPORT_URL}`); + await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json', + body: JSON.stringify({ success: false, error: 'The diagnostic report could not be built.' }) })); + await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click(); + await expect(toast(page)).toHaveText('The diagnostic report could not be downloaded. Nothing was saved to this device.'); + expect(saved).toBe(false); +}); + +test('Inspect opens the Inspect a file page and writes nothing', async ({ page, request }) => { + const before = await serverJson(request, '/__test__/system/digest'); + const mutations = []; + page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); }); + await openDiagnostics(page); + const row = operation(page, 'Image-link diagnostics'); + await expect(row.locator('p')).toHaveText('Check a file-to-prompt association without changing it.'); + await row.getByRole('button', { name: 'Inspect', exact: true }).click(); + await expect(page).toHaveURL(`${workspace}#/inspect`); + await expect(page.locator('#workspace-main h1')).toHaveText('Inspect a file'); + expect(mutations).toEqual([]); + expect(await serverJson(request, '/__test__/system/digest')).toEqual(before); +}); + +test('Create test link confirms first and names the prompt', async ({ page, request }) => { + const [prompt] = (await serverJson(request, '/prompt_manager/recent?limit=1')).results; + const posts = []; + page.on('request', item => { if (item.url().endsWith(TEST_LINK_URL)) posts.push(item.postDataJSON()); }); + await openDiagnostics(page); + const row = operation(page, 'Test image link'); + await expect(row.locator('p')).toHaveText('Write a placeholder image record to check that linking works.'); + const create = row.getByRole('button', { name: 'Create test link…', exact: true }); + await expect(create).not.toHaveAttribute('aria-disabled', 'true'); + await create.click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Create test link'); + await expect(dialog(page).locator('#modal-body > p')).toHaveText(new RegExp('^Writes a placeholder image record ' + + `\\(prompt_manager_test_link\\.png\\) linked to prompt #${prompt.id} “.+”\\. Maintenance › Missing-file links removes it later\\.$`)); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + expect(posts).toEqual([]); + await create.click(); + await dialog(page).getByRole('button', { name: 'Create', exact: true }).click(); + await expect(toast(page)).toHaveText(/^Test link created \(image record \d+\)\.$/); + expect(posts).toEqual([{ prompt_id: prompt.id }]); + // A failed write says nothing was changed. + await page.route(`**${TEST_LINK_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json', + body: JSON.stringify({ success: false, error: 'Test link failed' }) })); + await create.click(); + await dialog(page).getByRole('button', { name: 'Create', exact: true }).click(); + await expect(toast(page)).toHaveText('The test link could not be created. Nothing was changed.'); + // With no prompt to link, the button is unavailable and says why. + await page.route('**/prompt_manager/recent?limit=1', route => route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ success: true, results: [] }) })); + await openDiagnostics(page); + await expect(operation(page, 'Test image link').locator('p')) + .toHaveText('Write a placeholder image record to check that linking works. Add a prompt first.'); + await expect(operation(page, 'Test image link').getByRole('button')).toHaveAttribute('aria-disabled', 'true'); +}); + +// ── Task 5: the log viewer, statistics and runtime settings ───────────────────────────────────────────────────────── + +test('level filter is minimum-level over all six choices', async ({ page, request }) => { + await seedLogs(request); + const urls = []; + page.on('request', item => { if (new URL(item.url()).pathname === LOGS_URL) urls.push(new URL(item.url()).search); }); + await openDiagnostics(page); + const level = page.locator('#log-level'); + await expect(level.locator('option')).toHaveText(['All levels', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']); + await expect(page.locator('label[for="log-level"]')).toHaveText('Level'); + // All levels: the concept's four lines first, newest first, in its format. + await expect(lines(page)).toContainText('09:42:18 INFO Scan completed: 12 enriched, 0 duplicate links\n' + + '09:42:18 WARNING 3 images have ambiguous prompt metadata\n09:39:04 INFO Database backup verified\n' + + '09:38:52 INFO Source monitor started: output'); + for (const line of (await lines(page).textContent()).split('\n')) expect(line).toMatch(/^\d\d:\d\d:\d\d [A-Z]+ +\S/); + expect(urls.at(-1)).toBe('?limit=100'); + for (const minimum of LEVELS) { + await level.selectOption(minimum); + await expect.poll(() => urls.at(-1)).toBe(`?limit=100&level=${minimum}`); + await expect(lines(page)).toContainText('CRITICAL The database could not be opened'); + const shown = await shownLevels(page); + expect(shown.every(name => LEVELS.indexOf(name) >= LEVELS.indexOf(minimum)), `${minimum}: ${shown}`).toBe(true); + if (minimum === 'DEBUG') await expect(lines(page)).toContainText('DEBUG Thumbnail cache lookup'); + if (minimum === 'WARNING') { + await expect(lines(page)).not.toContainText('Database backup verified'); + await expect(lines(page)).toContainText('ERROR A source folder could not be read'); + } + } + await level.selectOption(''); + await expect.poll(() => urls.at(-1)).toBe('?limit=100'); +}); + +test('Lines accepts 1 to 500 and sends nothing when invalid', async ({ page, request }) => { + await seedLogs(request, { filler: 30 }); + const urls = []; + page.on('request', item => { if (new URL(item.url()).pathname === LOGS_URL) urls.push(new URL(item.url()).search); }); + await openDiagnostics(page); + const limit = page.locator('#log-limit'); + await expect(page.locator('label[for="log-limit"]')).toHaveText('Lines'); + await expect(limit).toHaveValue('100'); + expect(await limit.evaluate(node => [node.type, node.min, node.max, node.title])) + .toEqual(['number', '1', '500', 'Lines to show: a whole number from 1 to 500.']); + await limit.fill('20'); + await limit.dispatchEvent('change'); + await expect.poll(() => urls.at(-1)).toBe('?limit=20'); + await expect.poll(async () => (await lines(page).textContent()).split('\n').length).toBe(20); + const sent = urls.length; + for (const value of ['0', '501', '2.5', '']) { + await limit.fill(value); + await limit.dispatchEvent('change'); + await expect(limit).toHaveAttribute('aria-invalid', 'true'); + } + // A Level change while Lines is invalid sends nothing either. + await page.locator('#log-level').selectOption('WARNING'); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + expect(urls.length).toBe(sent); + await limit.fill('500'); + await limit.dispatchEvent('change'); + await expect(limit).not.toHaveAttribute('aria-invalid'); + await expect.poll(() => urls.at(-1)).toBe('?limit=500&level=WARNING'); +}); + +test('logs refresh handles empty, error and stale answers', async ({ page, request }) => { + await seedLogs(request); + await openDiagnostics(page); + const level = page.locator('#log-level'); + const refresh = surfaceOf(page, 'Logs').getByRole('button', { name: 'Refresh', exact: true }); + // Empty: nothing at this level once the buffer is cleared. + await expect(await request.post('/__test__/logs/reset')).toBeOK(); + await level.selectOption('CRITICAL'); + await expect(lines(page)).toHaveText('No log entries at this level.'); + // Error. + await page.route(`**${LOGS_URL}?*`, route => route.fulfill({ status: 500, contentType: 'application/json', + body: JSON.stringify({ success: false, error: 'boom', logs: [] }) })); + await refresh.click(); + await expect(lines(page)).toHaveText('The log could not be read.'); + await page.unroute(`**${LOGS_URL}?*`); + // Stale: an older answer that arrives after a newer request never paints. + await seedLogs(request); + await expect(await request.post('/__test__/hold', { data: { path: LOGS_URL } })).toBeOK(); + await level.selectOption('DEBUG'); + await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[LOGS_URL] ?? 0).toBe(1); + await level.selectOption('ERROR'); + await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[LOGS_URL] ?? 0).toBe(2); + await expect(await request.post('/__test__/release')).toBeOK(); + await expect(lines(page)).toContainText('CRITICAL The database could not be opened'); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + expect((await shownLevels(page)).every(name => ['ERROR', 'CRITICAL'].includes(name))).toBe(true); +}); + +test('log statistics show entries, errors, warnings, files and size', async ({ page, request }) => { + await seedLogs(request, { rotated: [2048, 1024], other: true, link: true, active: 512 }); + await openDiagnostics(page); + const stats = page.getByRole('status', { name: 'Log statistics', exact: true }); + await expect(stats).toHaveAttribute('id', 'log-stats'); + const text = await stats.textContent(); + const match = /^([\d,]+) entr(?:y|ies) in memory · (\d+) errors? · (\d+) warnings? · (\d+) files? · (.+) on disk$/.exec(text); + expect(match, text).not.toBeNull(); + const [, entries, errors, warnings, files, size] = match; + expect(Number(entries.replace(/,/g, ''))).toBeGreaterThanOrEqual(7); + expect(Number(errors)).toBeGreaterThanOrEqual(2); + expect(Number(warnings)).toBeGreaterThanOrEqual(1); + // The active log, two rotated copies and the .bak; the link is not counted (Rule R). + expect(Number(files)).toBe(4); + expect(size).toMatch(/^[\d.]+ (bytes|KB|MB)$/); +}); + +test('Download log saves the active log', async ({ page, request }) => { + await seedLogs(request, { active: 300 }); + await openDiagnostics(page); + const download = page.waitForEvent('download'); + await surfaceOf(page, 'Logs').getByRole('button', { name: 'Download log', exact: true }).click(); + const file = await download; + expect(file.suggestedFilename()).toBe('prompt_manager.log'); + expect((await readBlob(file)).length).toBeGreaterThanOrEqual(300); + await page.route('**/prompt_manager/logs/download/**', route => route.fulfill({ status: 404, contentType: 'application/json', + body: JSON.stringify({ success: false, error: 'Log file not found' }) })); + await surfaceOf(page, 'Logs').getByRole('button', { name: 'Download log', exact: true }).click(); + await expect(toast(page)).toHaveText('The log could not be downloaded.'); +}); + +test('runtime level (including CRITICAL) and console logging save and say they apply until the server restarts', + async ({ page, request }) => { + const posts = []; + page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith(CONFIG_URL)) posts.push(item.postDataJSON()); }); + await openDiagnostics(page); + const level = settingOf(page, 'Runtime log level'); + await expect(level.locator('p')).toHaveText('How much the server writes to the log. Applied until the server restarts.'); + await expect(level.locator('option')).toHaveText(['INFO', 'WARNING', 'DEBUG', 'ERROR', 'CRITICAL']); + await expect(level.locator('select')).toHaveValue('INFO'); + const consoleRow = settingOf(page, 'Console logging'); + await expect(consoleRow.locator('p')).toHaveText('Print Prompt Manager’s warnings and errors in the ComfyUI console ' + + '(everything at DEBUG). Applied until the server restarts.'); + const toggle = consoleRow.locator('input[type="checkbox"]'); + await expect(toggle).toBeChecked(); + await level.locator('select').selectOption('CRITICAL'); + await toggle.uncheck(); + await expect(status(page)).toHaveText('Unsaved changes.'); + await save(page).click(); + await expect(status(page)).toHaveText('Applied until the server restarts.'); + expect(posts).toEqual([{ level: 'CRITICAL', console_logging: false }]); + const { config } = await serverJson(request, CONFIG_URL); + expect([config.level, config.console_logging]).toEqual(['CRITICAL', false]); + // Read again on a fresh page: the server's values. + await openDiagnostics(page); + await expect(settingOf(page, 'Runtime log level').locator('select')).toHaveValue('CRITICAL'); + await expect(settingOf(page, 'Console logging').locator('input[type="checkbox"]')).not.toBeChecked(); + // Only the changed key is sent. + await settingOf(page, 'Runtime log level').locator('select').selectOption('DEBUG'); + await save(page).click(); + await expect(status(page)).toHaveText('Applied until the server restarts.'); + expect(posts.at(-1)).toEqual({ level: 'DEBUG' }); + }); + +test('a refused runtime value is marked invalid', async ({ page }) => { + await page.route(`**${CONFIG_URL}`, route => (route.request().method() === 'POST' + ? route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ success: false, error: 'Invalid log level' }) }) + : route.continue())); + await openDiagnostics(page); + const select = settingOf(page, 'Runtime log level').locator('select'); + await select.selectOption('WARNING'); + await save(page).click(); + await expect(status(page)).toHaveText('Some changes were not saved. Review the highlighted settings.'); + await expect(select).toHaveAttribute('aria-invalid', 'true'); + await expect(settingOf(page, 'Console logging').locator('input[type="checkbox"]')).not.toHaveAttribute('aria-invalid'); + // The draft stays: an edit clears the mark. + await expect(select).toHaveValue('WARNING'); + await select.selectOption('ERROR'); + await expect(select).not.toHaveAttribute('aria-invalid'); +}); + +test('runtime values that cannot be read stay disabled and say so', async ({ page }) => { + await page.route(`**${CONFIG_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json', + body: JSON.stringify({ success: false, error: 'boom' }) })); + await openDiagnostics(page); + for (const label of ['Runtime log level', 'Console logging']) { + const row = settingOf(page, label); + await expect(row.locator('p.small')).toHaveText('The current value could not be read.'); + await expect(row.locator('select, input[type="checkbox"]')).toBeDisabled(); + } +}); + +// ── Task 6: log files, the reviewed cleanup and Clear all logs ───────────────────────────────────────────────────── + +const LOG_FOLDER = { rotated: [3000, 2048, 1024], other: true, link: true, active: 100 }; +const LINKS = process.platform === 'win32' ? 0 : 1; + +test('Review log cleanup lists only rotated copies and keeps the rest', async ({ page, request }) => { + await seedLogs(request, LOG_FOLDER); + const before = await names(request); + const previews = []; + page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); }); + await openDiagnostics(page); + const details = await openFiles(page); + await expect(details.locator(':scope > p.small').first()).toHaveText(/^prompt_manager\.log · [\d.]+ (bytes|KB) · rotated copies: 3$/); + await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Review maintenance plan'); + await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies'); + expect(previews).toEqual([{ operation: 'rotated_logs', options: {} }]); + const grid = await dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item => + [item.querySelector('dt').textContent, item.querySelector('dd').textContent])); + expect(grid.map(([term]) => term)).toEqual(['Scope', 'Protected', 'Backup', 'Media on disk']); + expect(grid[0][1]).toMatch(/^3 rotated log copies · 5\.9 KB$/); + expect(grid[1][1]).toBe(['Kept: the active log', ...(LINKS ? ['Kept: 1 link'] : []), 'Kept: 1 other file'].join(' · ')); + expect(grid[2][1]).toBe('Not needed: no library record changes'); + expect(grid[3][1]).toBe('Deletes only the listed log files. No image, video or library record changes.'); + await expect(dialog(page).locator('details > summary')).toHaveText('Listed files (3)'); + await expect(dialog(page).locator('details pre')).toHaveText(/^prompt_manager\.log\.1 · 2\.9 KB · .+\nprompt_manager\.log\.2 · 2\.0 KB · .+\nprompt_manager\.log\.3 · 1\.0 KB · .+$/); + // No D55 backup box: nothing in the library changes. + await expect(dialog(page).locator('.notice')).toHaveCount(0); + await expect(dialog(page).getByRole('button', { name: 'Back up now' })).toHaveCount(0); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + expect(await names(request)).toEqual(before); +}); + +test('Apply deletes exactly the listed files and Activity lists them', async ({ page, request }) => { + await seedLogs(request, LOG_FOLDER); + await openDiagnostics(page); + const details = await openFiles(page); + await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click(); + await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + const job = await ended(request, page.url().split('/').pop()); + expect([job.kind, job.state, job.title, job.counters.changed]).toEqual(['maintenance', 'finished', 'Rotated log copies', 3]); + await expect(toast(page)).toHaveText(/Rotated log copies (started|finished · 3 changed)/); + const main = page.locator('#workspace-main'); + await expect(main.locator('h1')).toHaveText('Rotated log copies'); + for (const name of ['prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3']) await expect(main).toContainText(name); + // Only those three are gone: the active log, the user's .bak and the link are kept. + expect(await names(request)).toEqual(['prompt_manager.log', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak']); +}); + +test('a rollover after the review deletes nothing and Activity offers Review again', async ({ page, request }) => { + await seedLogs(request, LOG_FOLDER); + await openDiagnostics(page); + const details = await openFiles(page); + await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click(); + await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies'); + await expect(await request.post('/__test__/logs/rotate')).toBeOK(); + const rolled = await serverJson(request, '/__test__/logs/state'); + await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click(); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + const job = await ended(request, page.url().split('/').pop()); + expect([job.state, job.scope.failure]).toEqual(['failed', 'plan-changed']); + await expect(page.locator('#workspace-main')).toContainText(CHANGED); + expect((await serverJson(request, '/__test__/logs/state')).entries.map(entry => entry.name)) + .toEqual(rolled.entries.map(entry => entry.name)); + const previews = []; + page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); }); + await page.locator('#workspace-main').getByRole('button', { name: 'Review again', exact: true }).click(); + await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies'); + expect(previews).toEqual([{ operation: 'rotated_logs', options: {} }]); +}); + +test('Clear all logs states both counts, confirms first and reads everything again', async ({ page, request }) => { + await seedLogs(request, LOG_FOLDER); + const truncates = []; + page.on('request', item => { if (item.url().endsWith('/prompt_manager/logs/truncate')) truncates.push(item.url()); }); + await openDiagnostics(page); + await expect(lines(page)).toContainText('Database backup verified'); + const details = await openFiles(page); + const clearRow = operation(page, 'Clear all logs'); + await expect(clearRow.locator('p')).toHaveText('Empty the active log and delete its rotated copies.'); + const clear = clearRow.getByRole('button', { name: 'Clear all logs…', exact: true }); + await expect(clear).toHaveClass(/\bdanger\b/); + await clear.click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Clear all logs'); + await expect(dialog(page).locator('#modal-body > p')).toHaveText('Empties prompt_manager.log, deletes 3 rotated copies (5.9 KB) ' + + 'and clears the lines shown here. The other files in the log folder are kept. This cannot be undone.'); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + expect(truncates).toEqual([]); + await clear.click(); + await dialog(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await expect(toast(page)).toHaveText('Logs cleared · 3 rotated copies deleted.'); + expect(truncates).toHaveLength(1); + // Read again: the viewer, the file line and the list. + await expect(lines(page)).not.toContainText('Database backup verified'); + await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 0$/); + const kept = ['prompt_manager.log', 'prompt_manager.log.bak', ...(LINKS ? ['prompt_manager.log.4'] : [])]; + // The server lists newest first: compare as a set. + await expect.poll(async () => (await details.locator('.operation-list h3').allTextContents()).sort()).toEqual([...kept].sort()); + expect(await names(request)).toEqual(['prompt_manager.log', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak']); +}); + +test('Clear all logs reads the files again before it states what it deletes', async ({ page, request }) => { + await seedLogs(request, { rotated: [1024], active: 100 }); + await openDiagnostics(page); + const details = await openFiles(page); + await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 1$/); + // The log rolls over while the page stays open: Clear names what it deletes now, not the count shown. + await seedLogs(request, { records: false, rotated: [1024, 1024, 1024] }); + await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click(); + await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes 3 rotated copies \(3\.0 KB\) /); + await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 3$/); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + // A list that cannot be read gives the general sentence, never an old count. + await page.route('**/prompt_manager/logs/files', route => route.fulfill({ status: 500, contentType: 'application/json', + body: JSON.stringify({ success: false, error: 'boom', files: [] }) })); + await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click(); + await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes its rotated copies and clears/); + await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + expect(await names(request)).toEqual(['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3']); +}); + +test('each listed file downloads by its returned name and a link has no Download', async ({ page, request }) => { + await seedLogs(request, LOG_FOLDER); + await openDiagnostics(page); + const details = await openFiles(page); + await expect(details.locator(':scope > p.small').nth(1)) + .toHaveText('Rotates at 10 MB · keeps 5 rotated copies · file logging on · memory buffer 1,000 entries'); + const list = details.locator('.operation-list'); + expect((await list.locator('h3').allTextContents()).sort()).toEqual(['prompt_manager.log', 'prompt_manager.log.1', + 'prompt_manager.log.2', 'prompt_manager.log.3', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak'].sort()); + const item = name => list.locator('.operation').filter({ has: page.locator(`h3:text-is("${name}")`) }); + await expect(item('prompt_manager.log.1').locator('p')).toHaveText(/^2\.9 KB · \d{4}-\d\d-\d\d \d\d:\d\d · Rotated copy$/); + await expect(item('prompt_manager.log.bak').locator('p')).toHaveText(/^0 bytes · .+ · Other file$/); + if (LINKS) { + await expect(item('prompt_manager.log.4').locator('p')).toHaveText(/ · Link, not downloaded$/); + await expect(item('prompt_manager.log.4').getByRole('button')).toHaveCount(0); + } + for (const name of ['prompt_manager.log.1', 'prompt_manager.log.bak']) { + const download = page.waitForEvent('download'); + await item(name).getByRole('button', { name: `Download ${name}`, exact: true }).click(); + const file = await download; + expect(file.suggestedFilename()).toBe(name); + if (name === 'prompt_manager.log.1') expect((await readBlob(file)).length).toBe(3000); + } +}); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index 8e5dfa09..0988f993 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -412,15 +412,16 @@ test('out-of-order overview responses never repaint a newer section', async ({ p await nav.getByRole('button', { name: 'Appearance', exact: true }).click(); await nav.getByRole('button', { name: 'Overview', exact: true }).click(); await expect.poll(held).toBe(2); - // Diagnostics still hands off (S12), so its body is one known panel. + // Diagnostics & logs is native (S12): its body is its two surfaces. await nav.getByRole('button', { name: 'Diagnostics & logs', exact: true }).click(); + await expect(layout.locator(':scope > section.surface')).toHaveCount(2); await expect(await request.post('/__test__/release')).toBeOK(); await expect.poll(held).toBe(0); // Both late answers reached a section that had left: Diagnostics keeps its own body. - await expect(layout.locator(':scope > section.handoff')).toBeVisible(); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await expect(layout.locator('.summary-metrics')).toHaveCount(0); - expect(await layout.evaluate(node => [...node.children].map(child => child.className))).toEqual(['surface handoff', 'save-row']); + expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`))) + .toEqual(['section.surface', 'section.surface', 'div.save-row']); // Back on Overview, one fresh read paints one set of metrics. await nav.getByRole('button', { name: 'Overview', exact: true }).click(); await expect(layout.locator('.summary-metrics')).toHaveCount(1); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index e66a59df..fe11a135 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -18,7 +18,7 @@ const routes = [ ['tags', 'Tags & categories', null], // #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page. ['inspect', 'Inspect a file', null], - // #249 System S2: native System; its undelivered sections hand off per section (D94, asserted below). + // #249 System S2: native System; every section native since S12 (D94 retired, asserted below). ['system', 'System', null], // #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites. ['favorites', 'Favorites', null], @@ -395,7 +395,7 @@ const SURFACE = { intro: { fontSize: '12px', marginTop: '7px' }, }; -for (const width of [1600, 390]) test(`overview and handoff render with concept surfaces ${width}`, async ({ page }) => { +for (const width of [1600, 390]) test(`overview renders with concept surfaces and no System section hands off ${width}`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await page.goto(`${workspace}#/overview`); const main = page.locator('#workspace-main'); @@ -412,23 +412,23 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await sidebarLink(page, 'System').click(); - // System's Overview is native (S2 Task 6); a section not delivered yet still hands off (D94). + // Every System section is native (S12 retired D94): the last one, Diagnostics & logs, shows concept surfaces. await main.locator('.section-nav').getByRole('button', { name: 'Diagnostics & logs', exact: true }).click(); - const handoff = main.locator('section.handoff'); - await expect(handoff).toHaveClass(/\bsurface\b/); - expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE); - await expect(handoff.locator('a[href="/prompt_manager/admin"]')).toBeVisible(); + const logs = main.locator('.settings-layout > section.surface').last(); + await expect(logs.locator(':scope > h2')).toHaveText('Logs'); + expect(await logs.evaluate(surfaceStyle)).toMatchObject(SURFACE); + await expect(main.locator('.handoff')).toHaveCount(0); await expect(main.locator('.panel')).toHaveCount(0); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); }); -// #249 System S2 Task 5: the concept's renderSystem shell (app.js 698–706) at `#/system/
`, every section a D94 -// hand-off until its PR, and the Save row's truthful status (D92). +// #249 System S2 Task 5: the concept's renderSystem shell (app.js 698–706) at `#/system/
`, every section native +// (S12 retired the D94 hand-off), and the Save row's truthful status (D92). const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance', 'Backup & export', 'Diagnostics & logs']; const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics']; -test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => { +test('System is native: the concept shell, section navigation, sections and the Save row', async ({ page }) => { const mutations = []; page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); }); await page.goto(`${workspace}#/system`); @@ -466,7 +466,8 @@ test('System is native: the concept shell, section navigation, sections, hand-of // (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), Maintenance (S7 Task 19, // workspace-system-maintenance.spec.mjs), Backup & export (S9 Task 24, workspace-system-backups.spec.mjs) and - // Auto-tagging (S11 Task 6, workspace-system-tagging.spec.mjs); every other section hands off. + // Auto-tagging (S11 Task 6, workspace-system-tagging.spec.mjs) and Diagnostics & logs (S12, + // workspace-system-diagnostics.spec.mjs): none hands off. if (hash === 'tagging') await expect(layout.locator('#tag-model')).toBeVisible(); if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible(); if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2); @@ -476,13 +477,9 @@ test('System is native: the concept shell, section navigation, sections, hand-of integrations: ['section.surface', 'section.surface', 'div.save-row'], maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'], backups: ['div.two-col', 'section.surface', 'div.save-row'], - tagging: ['section.surface', 'div.save-row'] }[hash] - ?? ['section.surface handoff', 'div.save-row']); - if (['overview', 'appearance', 'sources', 'integrations', 'tagging', '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')))) - .toEqual(['/prompt_manager/admin']); + tagging: ['section.surface', 'div.save-row'], + diagnostics: ['section.surface', 'section.surface', 'div.save-row'] }[hash]); + await expect(layout.locator('.handoff')).toHaveCount(0); await expect(layout.locator('#settings-status')).toHaveText('No unsaved changes.'); await expect(page.locator('aside.sidebar a[href="#/system"]')).toHaveAttribute('aria-current', 'page'); } diff --git a/tests/browser/workspace_diagnostics_fixture.py b/tests/browser/workspace_diagnostics_fixture.py new file mode 100644 index 00000000..9a47009a --- /dev/null +++ b/tests/browser/workspace_diagnostics_fixture.py @@ -0,0 +1,241 @@ +"""System › Diagnostics & logs test hooks for the browser tests (#249 System S12). + +The logger writes into ``/logs`` (``repoint_logs``, called by +``create_app`` before any record is written), so no test reads or deletes a +real log folder. Every route refuses a log folder outside the server root. + +- ``POST /__test__/logs/reset`` restores the default logging configuration, + empties the active log, removes every other entry of the log folder (a + link is removed, never followed) and clears the in-memory buffer. The + global ``/__test__/reset`` calls it too; +- ``POST /__test__/logs/seed`` ``{records, filler, rotated, active, link, + other}`` writes buffer records with fixed times (``records``: the concept's + four lines, then one DEBUG, ERROR and CRITICAL record, older; ``filler`` + more INFO records older still), ``active`` bytes into the active log, + rotated copies ``prompt_manager.log.`` of the listed sizes (fixed + times), on POSIX a symlink ``prompt_manager.log.`` to a file outside + the folder (``link``) and a user's ``prompt_manager.log.bak`` (``other``); +- ``POST /__test__/logs/rotate`` rolls the copies over as the handler does + (``.2`` → ``.3``, ``.1`` → ``.2``, a new ``.1``), so a reviewed plan no + longer matches; +- ``GET /__test__/logs/state`` answers the folder's entries (name, size, link) + and the buffer's length. +""" + +import datetime +import logging +import os +import shutil +from pathlib import Path + +from aiohttp import web + +ACTIVE = "prompt_manager.log" +SEED_DAY = datetime.datetime(2026, 10, 26) +# The concept's four lines (app.js 695), oldest first: the viewer shows them +# newest first. +CONCEPT_RECORDS = ( + ("09:38:52", logging.INFO, "Source monitor started: output"), + ("09:39:04", logging.INFO, "Database backup verified"), + ("09:42:18", logging.WARNING, "3 images have ambiguous prompt metadata"), + ("09:42:18", logging.INFO, "Scan completed: 12 enriched, 0 duplicate links"), +) +# Older than the concept's lines, so they come after them: the levels the +# concept does not show. +LEVEL_RECORDS = ( + ("09:30:01", logging.DEBUG, "Thumbnail cache lookup"), + ("09:30:02", logging.ERROR, "A source folder could not be read"), + ("09:30:03", logging.CRITICAL, "The database could not be opened"), +) +ROTATED_TIME = datetime.datetime(2026, 10, 25, 14, 2) +MAX_SEED_BYTES = 64 * 1024 * 1024 +DEFAULT_CONFIG = { + "level": "INFO", + "max_file_size": 10 * 1024 * 1024, + "backup_count": 5, + "console_logging": True, + "file_logging": True, + "buffer_size": 1000, +} + + +def _manager(): + from utils.logging_config import get_logger_manager + + return get_logger_manager() + + +def repoint_logs(root: Path) -> Path: + """Point the logger at ``/logs`` before anything is logged. + + The logger is built while ``logs_dir`` answers that folder, so its first + handler never opens a log file anywhere else (a node folder with a legacy + database would otherwise name a real log folder). + """ + from utils import library_location + + folder = Path(root) / "logs" + folder.mkdir(exist_ok=True) + original = library_location.logs_dir + library_location.logs_dir = lambda **_kwargs: str(folder) + try: + manager = _manager() + finally: + library_location.logs_dir = original + with manager._files_lock: + manager.log_dir = folder + manager._setup_loggers() + return folder + + +def _require_inside(root: Path, folder: Path) -> None: + resolved = Path(os.path.realpath(folder)) + if Path(os.path.realpath(root)) not in resolved.parents: + raise web.HTTPForbidden(text="The log folder is outside the test server root") + + +def _record(time_text, level, message, index): + record = logging.LogRecord( + "prompt_manager.test", level, __file__, index, message, None, None + ) + hours, minutes, seconds = (int(part) for part in time_text.split(":")) + moment = SEED_DAY.replace(hour=hours, minute=minutes, second=seconds) + # Equal times keep their order: each later record is a microsecond newer. + record.created = moment.timestamp() + index / 1e6 + return record + + +def _touch(path: Path, size: int, moment: datetime.datetime) -> None: + # A link at this name is replaced, never written through. + if path.is_symlink(): + path.unlink() + with open(path, "wb") as handle: + handle.write(b"x" * size) + stamp = moment.timestamp() + os.utime(path, (stamp, stamp)) + + +def _bounded(value, name, top=MAX_SEED_BYTES): + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= top: + raise web.HTTPBadRequest(text=f"{name} must be an integer 0..{top}") + return value + + +def register_diagnostics_commands(app, routes, api, root: Path): + """Register the hooks; returns ``reset()`` for the global reset.""" + + def folder() -> Path: + path = Path(_manager().log_dir) + _require_inside(root, path) + return path + + def reset() -> None: + manager = _manager() + log_dir = folder() + if manager.get_config() != DEFAULT_CONFIG: + manager.update_config(dict(DEFAULT_CONFIG)) + with manager._files_lock: + held = manager._acquire_file_handlers() + try: + with os.scandir(log_dir) as entries: + found = list(entries) + for entry in found: + path = Path(entry.path) + if entry.name == ACTIVE and entry.is_file(follow_symlinks=False): + manager._truncate_main_log(path) + elif entry.is_dir(follow_symlinks=False): + shutil.rmtree(path) + else: + os.unlink(path) + finally: + manager._release_handlers(held) + with manager._buffer_lock: + manager._log_buffer.clear() + + def seed(data) -> None: + manager = _manager() + log_dir = folder() + with_records = data.get("records", True) is not False + filler = _bounded(data.get("filler", 0), "filler", 900) + older = [ + (f"08:{index // 60:02}:{index % 60:02}", logging.INFO, f"Filler {index}") + for index in range(filler) + ] + named = list(LEVEL_RECORDS) + list(CONCEPT_RECORDS) if with_records else [] + ordered = older + named + for index, (time_text, level, message) in enumerate(ordered): + record = _record(time_text, level, message, index) + manager.add_to_buffer(record, record.getMessage()) + active = _bounded(data.get("active", 0), "active") + if active: + with open(log_dir / ACTIVE, "ab") as handle: + handle.write(b"x" * active) + rotated = data.get("rotated", []) + if not isinstance(rotated, list) or len(rotated) > 20: + raise web.HTTPBadRequest(text="rotated must be a list of sizes") + for number, size in enumerate(rotated, start=1): + moment = ROTATED_TIME - datetime.timedelta(hours=number) + _touch(log_dir / f"{ACTIVE}.{number}", _bounded(size, "size"), moment) + if data.get("link") and os.name != "nt": + target = Path(root) / "outside-log-target.txt" + target.write_bytes(b"not a log of this folder") + os.symlink(target, log_dir / f"{ACTIVE}.{len(rotated) + 1}") + if data.get("other"): + _touch(log_dir / f"{ACTIVE}.bak", 0, ROTATED_TIME) + + def rotate() -> None: + manager = _manager() + log_dir = folder() + with manager._files_lock: + held = manager._acquire_file_handlers() + try: + for number in (2, 1): + source = log_dir / f"{ACTIVE}.{number}" + if source.is_file() and not source.is_symlink(): + os.replace(source, log_dir / f"{ACTIVE}.{number + 1}") + _touch(log_dir / f"{ACTIVE}.1", 4096, datetime.datetime.now()) + finally: + manager._release_handlers(held) + + def state(): + manager = _manager() + log_dir = folder() + entries = [] + with os.scandir(log_dir) as listing: + found = sorted(listing, key=lambda item: item.name) + for entry in found: + info = entry.stat(follow_symlinks=False) + entries.append( + {"name": entry.name, "size": info.st_size, "link": entry.is_symlink()} + ) + with manager._buffer_lock: + buffered = len(manager._log_buffer) + return {"entries": entries, "buffer": buffered, "config": manager.get_config()} + + async def body(request): + data = await request.json() if request.can_read_body else {} + if not isinstance(data, dict): + raise web.HTTPBadRequest() + return data + + @routes.post("/__test__/logs/reset") + async def logs_reset(request: web.Request) -> web.Response: + await api._run_in_executor(reset) + return web.json_response({"success": True}) + + @routes.post("/__test__/logs/seed") + async def logs_seed(request: web.Request) -> web.Response: + data = await body(request) + await api._run_in_executor(seed, data) + return web.json_response({"success": True}) + + @routes.post("/__test__/logs/rotate") + async def logs_rotate(request: web.Request) -> web.Response: + await api._run_in_executor(rotate) + return web.json_response({"success": True}) + + @routes.get("/__test__/logs/state") + async def logs_state(request: web.Request) -> web.Response: + return web.json_response(await api._run_in_executor(state)) + + return reset diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index a6779df2..b646dc5a 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -23,7 +23,7 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy assert.equal(routeTitle(route), route.label); } assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library'); - // System is native from S2 (#249 System): its undelivered sections hand off per section (D94), not the route. + // System is native from S2 (#249 System), and every section of it from S12 (D94 retired), not the route. for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity', 'review', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id); }); diff --git a/tests/js/workspace-system-diagnostics.test.js b/tests/js/workspace-system-diagnostics.test.js new file mode 100644 index 00000000..65b0bddb --- /dev/null +++ b/tests/js/workspace-system-diagnostics.test.js @@ -0,0 +1,193 @@ +// System › Diagnostics & logs (#249 System S12, #329): the concept's two surfaces (app.js 694–695) with live values: the +// meta-grid texts, the report's name and merge (D110), the log viewer's lines, Lines validation, statistics, retention +// and file rows, and the Clear all logs copy (D109). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const diagnostics = import('../../web/js/workspace/system-diagnostics.mjs'); + +const CONCEPT = fs.readFileSync(path.join(__dirname, '../../docs/concepts/workspace/app.js'), 'utf8'); +const SOURCE = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/system-diagnostics.mjs'), 'utf8'); +const SYSTEM = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/system.mjs'), 'utf8'); + +const REPORT = { + checks: { database: { status: 'ok', prompt_count: 4210, has_images_table: true, image_count: 19956 } }, + database_info: { database_path: 'user/__promptmanager/prompts.db', database_size_bytes: 1717986918 }, + sources: { configured: 2, effective: 2, watching: 2, unavailable: 0 }, + viewerjs: '1.15.2', +}; + +test('the section keeps the concept surfaces, terms, rows and labels; no sample copy ships', () => { + const start = CONCEPT.indexOf('case "diagnostics":'); + const body = CONCEPT.slice(start, CONCEPT.indexOf('break;', start)); + const terms = [...body.matchAll(/
([^<]+)<\/dt>/g)].map(([, term]) => term); + assert.deepEqual(terms, ['Database', 'Sources', 'Viewer.js', 'Host renderers']); + for (const text of ["'Diagnostics'", "'Logs'", "'Run checks'", "'Refresh'", "'Level'", "'Log files & retention'", + "'Review log cleanup'", "'Runtime log level'", "'Console logging'", "'Image-link diagnostics'"]) { + assert.ok(SOURCE.includes(text), text); + } + for (const id of ['runtime-level', 'console-log']) assert.ok(body.includes(`setting("${id}"`) && SOURCE.includes(`id: '${id}'`), id); + assert.ok(SOURCE.includes("'toolbar spaced-16'") && body.includes('class="toolbar spaced-16"')); + // Prototype copy is never shipped. + for (const sample of ['sample', 'Sample']) assert.ok(!SOURCE.includes(`'${sample}`) && !/Download sample|Inspect sample/.test(SOURCE), sample); +}); + +test('D94 is retired: Diagnostics & logs is a delivered section and nothing hands off', () => { + assert.ok(SYSTEM.includes('diagnostics: mountDiagnostics')); + assert.ok(!SYSTEM.includes('SECTION_HANDOFFS')); + assert.ok(!SYSTEM.includes('handoff(')); + assert.ok(!SYSTEM.includes('NOT_DELIVERED')); +}); + +test('database text covers readable, not readable, busy and loading', async () => { + const { databaseText, LOADING, UNREADABLE } = await diagnostics; + assert.equal(databaseText({ report: REPORT }), 'Readable · 1.6 GB · 19,956 images'); + assert.equal(databaseText({ report: { ...REPORT, checks: { database: { status: 'ok', image_count: 1 } }, + database_info: { database_size_bytes: 2048 } } }), 'Readable · 2.0 KB · 1 image'); + assert.equal(databaseText({ report: { checks: { database: { status: 'error', message: 'Database error: locked' } } } }), + 'Not readable: Database error: locked'); + assert.equal(databaseText({ loading: true }), LOADING); + assert.equal(LOADING, 'Loading…'); + assert.equal(databaseText({ error: { code: 'busy', running: { id: 'j1', title: 'Restore' } } }), 'Busy: Restore is running.'); + assert.equal(databaseText({ error: { code: 'busy' } }), 'Busy: another job is running.'); + assert.equal(databaseText({ error: new Error('offline') }), UNREADABLE); + assert.equal(UNREADABLE, 'Could not be read.'); +}); + +test('sources text follows the precedence table', async () => { + const { sourcesText } = await diagnostics; + const of = sources => sourcesText({ report: { sources } }); + assert.equal(of({ configured: 0, effective: 1, watching: 1, unavailable: 0 }), 'ComfyUI output folder · watching'); + assert.equal(of({ configured: 0, effective: 1, watching: 0, unavailable: 0 }), 'ComfyUI output folder · manual scan'); + // Unavailable wins over watching. + assert.equal(of({ configured: 3, effective: 2, watching: 2, unavailable: 1 }), '3 sources · 1 not available'); + assert.equal(of({ configured: 2, effective: 2, watching: 2, unavailable: 0 }), '2 sources · 2 watching'); + assert.equal(of({ configured: 1, effective: 1, watching: 0, unavailable: 0 }), '1 source · 0 watching'); + assert.equal(of(undefined), 'ComfyUI output folder · manual scan'); +}); + +test('Viewer.js reads the pinned vendor version', async () => { + const { viewerText } = await diagnostics; + assert.equal(viewerText({ report: REPORT }), '1.15.2 · pinned vendor'); + assert.equal(viewerText({ report: {} }), 'unknown · pinned vendor'); +}); + +test('report filename uses local time', async () => { + const { reportFilename } = await diagnostics; + assert.equal(reportFilename(new Date(2026, 9, 6, 7, 5)), 'prompt-manager-diagnostics-20261006-0705.json'); + assert.equal(reportFilename(new Date(2026, 11, 31, 23, 59)), 'prompt-manager-diagnostics-20261231-2359.json'); +}); + +test('merged report adds only host_renderers', async () => { + const { mergeReport } = await diagnostics; + const merged = mergeReport(REPORT, 'Detected: Classic (1 ComfyUI window).'); + assert.deepEqual(Object.keys(merged).sort(), [...Object.keys(REPORT), 'host_renderers'].sort()); + assert.equal(merged.host_renderers, 'Detected: Classic (1 ComfyUI window).'); + assert.equal(mergeReport(REPORT, null).host_renderers, null); + assert.equal(mergeReport(REPORT, undefined).host_renderers, null); + // The server's fields are not changed. + assert.equal(merged.sources, REPORT.sources); +}); + +test('sizes read as the server labels them (1 KB = 1024 bytes)', async () => { + const { sizeText } = await diagnostics; + assert.equal(sizeText(0), '0 bytes'); + assert.equal(sizeText(1), '1 byte'); + assert.equal(sizeText(1000), '1,000 bytes'); + assert.equal(sizeText(2048), '2.0 KB'); + assert.equal(sizeText(10 * 2 ** 20), '10.0 MB'); + assert.equal(sizeText(1717986918), '1.6 GB'); + assert.equal(sizeText(undefined), '0 bytes'); +}); + +test("log line uses the concept's format", async () => { + const { logLine } = await diagnostics; + assert.equal(logLine({ timestamp: '2026-10-26T09:42:18.123456', level: 'INFO', message: 'Database backup verified' }), + '09:42:18 INFO Database backup verified'); + assert.equal(logLine({ timestamp: '2026-10-26T09:42:18', level: 'WARNING', message: '3 images have ambiguous prompt metadata' }), + '09:42:18 WARNING 3 images have ambiguous prompt metadata'); + assert.match(logLine({ timestamp: '2026-10-26T09:42:18', level: 'ERROR', message: 'x' }), /^\d\d:\d\d:\d\d [A-Z]+ +\S/); + // The concept's sample lines read the same way. + assert.ok(CONCEPT.includes('09:42:18 INFO Scan completed')); +}); + +test('Lines accepts whole numbers 1 to 500 only', async () => { + const { validLimit, logsUrl } = await diagnostics; + for (const [value, expected] of [['1', 1], ['500', 500], [' 20 ', 20], ['100', 100]]) assert.equal(validLimit(value), expected, value); + for (const value of ['0', '501', '2.5', '', ' ', '-1', '1e2', 'abc', undefined]) assert.equal(validLimit(value), null, String(value)); + assert.equal(logsUrl('', 20), '/prompt_manager/logs?limit=20'); + assert.equal(logsUrl('WARNING', 20), '/prompt_manager/logs?limit=20&level=WARNING'); +}); + +test('the level filter offers all six minimum levels; the runtime level adds CRITICAL after the concept four', async () => { + const { LEVEL_OPTIONS, RUNTIME_LEVELS } = await diagnostics; + assert.deepEqual(LEVEL_OPTIONS.map(([, label]) => label), ['All levels', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']); + assert.equal(LEVEL_OPTIONS[0][0], ''); + assert.deepEqual(RUNTIME_LEVELS, ['INFO', 'WARNING', 'DEBUG', 'ERROR', 'CRITICAL']); + assert.ok(CONCEPT.includes('["INFO", "WARNING", "DEBUG", "ERROR"]')); +}); + +test('statistics text uses singular forms for one', async () => { + const { statsText } = await diagnostics; + assert.equal(statsText({ buffer_count: 1000, level_counts: { INFO: 980, WARNING: 12, ERROR: 2, CRITICAL: 1 }, + log_files_count: 5, total_log_size: 43201024 }), '1,000 entries in memory · 3 errors · 12 warnings · 5 files · 41.2 MB on disk'); + assert.equal(statsText({ buffer_count: 1, level_counts: { WARNING: 1, CRITICAL: 1 }, log_files_count: 1, total_log_size: 1 }), + '1 entry in memory · 1 error · 1 warning · 1 file · 1 byte on disk'); + assert.equal(statsText({}), '0 entries in memory · 0 errors · 0 warnings · 0 files · 0 bytes on disk'); +}); + +test('retention, file line and file rows read the server values', async () => { + const { retentionText, fileLine, fileDetail } = await diagnostics; + assert.equal(retentionText({ max_file_size: 10485760, backup_count: 5, file_logging: true, buffer_size: 1000 }), + 'Rotates at 10 MB · keeps 5 rotated copies · file logging on · memory buffer 1,000 entries'); + assert.equal(retentionText({ max_file_size: 1572864, backup_count: 1, file_logging: false, buffer_size: 1 }), + 'Rotates at 1.5 MB · keeps 1 rotated copy · file logging off · memory buffer 1 entry'); + const files = [ + { filename: 'prompt_manager.log', kind: 'active', size: 1468006, modified: '2026-10-26T09:42:18' }, + { filename: 'prompt_manager.log.1', kind: 'rotated', size: 10485760, modified: '2026-10-25T14:02:00' }, + { filename: 'prompt_manager.log.4', kind: 'link', size: 20, modified: '2026-10-25T14:02:00' }, + { filename: 'prompt_manager.log.bak', kind: 'other', size: 0, modified: '2026-10-24T08:00:00' }, + ]; + assert.equal(fileLine(files), 'prompt_manager.log · 1.4 MB · rotated copies: 1'); + assert.equal(fileLine([]), 'prompt_manager.log · not written yet · rotated copies: 0'); + assert.equal(fileDetail(files[1]), '10.0 MB · 2026-10-25 14:02 · Rotated copy'); + assert.equal(fileDetail(files[2]), '20 bytes · 2026-10-25 14:02 · Link, not downloaded'); + assert.equal(fileDetail(files[3]), '0 bytes · 2026-10-24 08:00 · Other file'); + assert.equal(fileDetail(files[0]), '1.4 MB · 2026-10-26 09:42 · Active log'); +}); + +test('Clear all logs states both counts and its toast says what was done', async () => { + const { clearSentence, clearToast } = await diagnostics; + const rotated = size => ({ kind: 'rotated', size }); + const files = [{ kind: 'active', size: 10 }, rotated(10 * 2 ** 20), rotated(10 * 2 ** 20), rotated(8.4 * 2 ** 20), { kind: 'other', size: 5 }]; + assert.equal(clearSentence(files), 'Empties prompt_manager.log, deletes 3 rotated copies (28.4 MB) and clears the lines shown ' + + 'here. The other files in the log folder are kept. This cannot be undone.'); + assert.equal(clearSentence([{ kind: 'active', size: 1 }]), 'Empties prompt_manager.log and clears the lines shown here. ' + + 'The other files in the log folder are kept. This cannot be undone.'); + assert.match(clearSentence(null), /^Empties prompt_manager.log, deletes its rotated copies and clears/); + assert.equal(clearToast({ truncated: ['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3'], + errors: [] }), 'Logs cleared · 3 rotated copies deleted.'); + assert.equal(clearToast({ truncated: ['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2'], + errors: ['Failed to truncate prompt_manager.log.3: in use'] }), 'Logs cleared · 2 rotated copies deleted · 1 could not be deleted.'); + assert.equal(clearToast({ truncated: ['prompt_manager.log', 'prompt_manager.log.1'], errors: [] }), 'Logs cleared · 1 rotated copy deleted.'); + assert.equal(clearToast(undefined), 'Logs cleared · 0 rotated copies deleted.'); +}); + +test('the test-link dialog names the prompt and how Missing-file links removes the record', async () => { + const { testLinkSentence, promptName } = await diagnostics; + assert.equal(testLinkSentence({ id: 812, text: 'Misty harbor' }), 'Writes a placeholder image record ' + + '(prompt_manager_test_link.png) linked to prompt #812 “Misty harbor”. Maintenance › Missing-file links removes it later.'); + assert.equal(promptName({ text: ' a\n b ' }), 'a b'); + assert.equal(promptName({ text: 'x'.repeat(80) }).length, 60); +}); + +test('runtime values and body map config.level to runtime_level and back', async () => { + const { runtimeValues, runtimeBody } = await diagnostics; + assert.deepEqual(runtimeValues({ level: 'INFO', console_logging: true, max_file_size: 1 }), { runtime_level: 'INFO', console_logging: true }); + assert.deepEqual(runtimeBody({ runtime_level: 'CRITICAL' }), { level: 'CRITICAL' }); + assert.deepEqual(runtimeBody({ console_logging: false }), { console_logging: false }); + assert.deepEqual(runtimeBody({ runtime_level: 'DEBUG', console_logging: true }), { level: 'DEBUG', console_logging: true }); + assert.deepEqual(runtimeBody({}), {}); +}); diff --git a/tests/js/workspace-system-maintenance.test.js b/tests/js/workspace-system-maintenance.test.js index 7575f2ec..c085e22b 100644 --- a/tests/js/workspace-system-maintenance.test.js +++ b/tests/js/workspace-system-maintenance.test.js @@ -169,3 +169,44 @@ test('a plan with nothing to do offers no Apply (S7 live test)', async () => { assert.equal(hasWork({ ...PLAN, scope: {} }), true); assert.equal(NOTHING_TO_APPLY, 'There is nothing to apply; nothing was changed.'); }); + +// S12 (#329): the rotated-log cleanup plan changes no library record: its Backup reads "Not needed", the review shows no +// D55 backup box, and its details are files. +const LOG_PLAN = { operation: 'rotated_logs', token: 't', expires_at: '2026-10-26T23:15:00', + scope: { count: 3, label: '3 rotated log copies · 28.4 MB' }, + protected: { count: 3, reasons: ['Kept: the active log', 'Kept: 1 link', 'Kept: 1 other file'] }, + backup: { policy: 'none', destination_label: null, expected_bytes: null }, + media: { deletes_files: true, label: 'Deletes only the listed log files. No image, video or library record changes.' }, + details: [{ label: 'prompt_manager.log.1', detail: '10.0 MB · 2026-10-25 14:02' }] }; + +test('a plan without a database change reads Not needed and shows no backup box', async () => { + const { planGrid, backupText, backupBox, BACKUP_COPY, OPERATION_TITLES } = await review; + assert.equal(backupText({ policy: 'none' }), 'Not needed: no library record changes'); + assert.deepEqual(planGrid(LOG_PLAN), [ + ['Scope', '3 rotated log copies · 28.4 MB'], + ['Protected', 'Kept: the active log · Kept: 1 link · Kept: 1 other file'], + ['Backup', 'Not needed: no library record changes'], + ['Media on disk', 'Deletes only the listed log files. No image, video or library record changes.'], + ]); + const onBackup = () => {}; + assert.equal(backupBox(LOG_PLAN, onBackup), null); + // Every database plan keeps its D55 box. + assert.deepEqual(backupBox({ ...LOG_PLAN, operation: 'optimize', backup: { policy: 'required' } }, onBackup), { copy: BACKUP_COPY, onBackup }); + assert.deepEqual(backupBox({ operation: 'missing_links' }, onBackup), { copy: BACKUP_COPY, onBackup }); + // Its title heads the dialog and lets Activity offer Review again. + assert.equal(OPERATION_TITLES.rotated_logs, 'Rotated log copies'); +}); + +test('rotated_logs details read Listed files', async () => { + const { detailsSummary, detailLines } = await review; + assert.equal(detailsSummary(LOG_PLAN, 3), 'Listed files (3)'); + assert.equal(detailsSummary({ ...LOG_PLAN, scope: { count: 60 } }, 50), 'Listed files (50 of 60)'); + assert.equal(detailsSummary({ operation: 'missing_links', scope: { count: 2 } }, 2), 'Listed records (2)'); + assert.deepEqual(detailLines(LOG_PLAN), ['prompt_manager.log.1 · 10.0 MB · 2026-10-25 14:02']); +}); + +test('a rotated-log cleanup the log overtook offers Review again (S12)', async () => { + const { reviewAgainOf } = await jobView; + assert.deepEqual(reviewAgainOf({ kind: 'maintenance', state: 'failed', scope: { operation: 'rotated_logs', failure: 'plan-changed' } }), + { operation: 'rotated_logs' }); +}); diff --git a/tests/js/workspace-system-state.test.js b/tests/js/workspace-system-state.test.js index 4a60d5a2..edef02d7 100644 --- a/tests/js/workspace-system-state.test.js +++ b/tests/js/workspace-system-state.test.js @@ -420,3 +420,12 @@ test('a partial host save keeps what saved, adopts its revision and fails only t assert.equal((await drafts.save()).host, 'failed'); assert.equal(drafts.statusText(), SETTINGS_STATUS.partial); }); + +// S12 (#329): System's runtime domain is the running server's log level and console setting (`/logs/config`). +test('runtime adapter maps config.level to runtime_level and back', async () => { + const { runtimeValues, runtimeBody } = await import('../../web/js/workspace/system-diagnostics.mjs'); + assert.deepEqual(runtimeValues({ level: 'WARNING', console_logging: false, backup_count: 5 }), + { runtime_level: 'WARNING', console_logging: false }); + assert.deepEqual(runtimeBody({ runtime_level: 'WARNING' }), { level: 'WARNING' }); + assert.deepEqual(runtimeBody({ runtime_level: 'CRITICAL', console_logging: true }), { level: 'CRITICAL', console_logging: true }); +}); diff --git a/tests/test_api_core.py b/tests/test_api_core.py index e6e06bec..07e6ffa7 100644 --- a/tests/test_api_core.py +++ b/tests/test_api_core.py @@ -1,5 +1,6 @@ """Tests for the core API module (py/api/__init__.py): helpers and base routes.""" +import asyncio import json import os import shutil @@ -165,8 +166,19 @@ class TestGzipMiddleware(AioHTTPTestCase): await resp.write_eof() return resp + async def declared_length(request): + # The log download (#329) declares its length before the middleware + # compresses it. + body = b"x" * 3000 + return web.Response( + body=body, + content_type="text/plain", + headers={"Content-Length": str(len(body))}, + ) + app = web.Application(middlewares=[api_module._gzip_middleware]) app.router.add_get("/prompt_manager/big", big_json) + app.router.add_get("/prompt_manager/declared", declared_length) app.router.add_get("/prompt_manager/small", small_json) app.router.add_get("/prompt_manager/encoded", already_encoded) app.router.add_get("/prompt_manager/binary", binary) @@ -193,6 +205,18 @@ class TestGzipMiddleware(AioHTTPTestCase): self.assertIn(b'"data"', gzip.decompress(await resp.read())) + async def test_compressed_body_declares_its_own_length(self): + # A stale Content-Length (the uncompressed size) makes a browser wait + # for bytes that never come (#329: Download log hung on any log > 1 KB). + resp = await self._get("/prompt_manager/declared") + self.assertEqual(resp.headers.get("Content-Encoding"), "gzip") + # Bounded: a stale length would otherwise wait for the missing bytes. + body = await asyncio.wait_for(resp.read(), timeout=10) + self.assertEqual(int(resp.headers["Content-Length"]), len(body)) + import gzip + + self.assertEqual(gzip.decompress(body), b"x" * 3000) + async def test_small_body_not_compressed(self): resp = await self._get("/prompt_manager/small") self.assertIsNone(resp.headers.get("Content-Encoding")) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 3a914de7..140905b4 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -1,9 +1,9 @@ { "admin.js": { - "lines": 32, + "lines": 37, "branches": 61, - "functions": 27, - "statements": 32 + "functions": 32, + "statements": 37 }, "api-client.js": { "lines": 98, @@ -48,10 +48,10 @@ "statements": 98 }, "gallery.js": { - "lines": 45, - "branches": 66, - "functions": 48, - "statements": 45 + "lines": 49, + "branches": 68, + "functions": 50, + "statements": 49 }, "html-escape.js": { "lines": 98, @@ -133,7 +133,7 @@ }, "sse-stream.js": { "lines": 98, - "branches": 90, + "branches": 92, "functions": 98, "statements": 98 }, @@ -181,7 +181,7 @@ }, "workspace/activity-scan.mjs": { "lines": 96, - "branches": 87, + "branches": 90, "functions": 98, "statements": 96 }, @@ -420,10 +420,10 @@ "statements": 98 }, "workspace/maintenance-review.mjs": { - "lines": 94, - "branches": 86, - "functions": 90, - "statements": 94 + "lines": 95, + "branches": 87, + "functions": 91, + "statements": 95 }, "workspace/media-viewer.mjs": { "lines": 98, @@ -515,15 +515,21 @@ "functions": 89, "statements": 98 }, + "workspace/system-diagnostics.mjs": { + "lines": 95, + "branches": 86, + "functions": 94, + "statements": 95 + }, "workspace/system-integrations.mjs": { - "lines": 96, - "branches": 88, - "functions": 91, - "statements": 96 + "lines": 97, + "branches": 90, + "functions": 92, + "statements": 97 }, "workspace/system-maintenance.mjs": { "lines": 93, - "branches": 80, + "branches": 81, "functions": 92, "statements": 93 }, @@ -546,10 +552,10 @@ "statements": 98 }, "workspace/system-tagging.mjs": { - "lines": 80, - "branches": 70, - "functions": 75, - "statements": 80 + "lines": 98, + "branches": 94, + "functions": 95, + "statements": 98 }, "workspace/system-thumbnails.mjs": { "lines": 98, @@ -560,7 +566,7 @@ "workspace/system.mjs": { "lines": 98, "branches": 92, - "functions": 86, + "functions": 98, "statements": 98 }, "workspace/theme.mjs": { diff --git a/web/js/workspace/maintenance-review.mjs b/web/js/workspace/maintenance-review.mjs index a5c98498..75fcd91a 100644 --- a/web/js/workspace/maintenance-review.mjs +++ b/web/js/workspace/maintenance-review.mjs @@ -53,7 +53,14 @@ export const OPERATION_TITLES = Object.freeze({ duplicate_files: 'Duplicate image files', // Reviewed and applied through the API and seen in Activity; Maintenance has no row for it (the concept has none). workflow_storage: 'Store each workflow once', + // S12: Diagnostics & logs › Log files & retention › Review log cleanup (the concept's `maintenance-plan` "rotated logs"). + rotated_logs: 'Rotated log copies', }); +/** S12: plans whose details are files, not records ("Listed files"). */ +const FILE_PLANS = new Set(['rotated_logs']); +/** S12: a plan that changes no library record (`backup.policy: "none"`): no backup is taken or offered. */ +export const NO_BACKUP_TEXT = 'Not needed: no library record changes'; +const noBackup = plan => plan?.backup?.policy === 'none'; // The review is gone or no longer matches the database: review it again. const REVIEW_AGAIN = new Set(['plan-expired', 'storage-changed', 'plan-changed']); // Refusals whose server message is the whole truth. @@ -71,6 +78,7 @@ function el(tag, text, className) { /** The Backup value: what the apply will do before it changes anything. */ export function backupText(backup) { + if (backup?.policy === 'none') return NO_BACKUP_TEXT; if (backup?.policy === 'off') return 'No safety backup (the policy is off)'; const where = backup?.destination_label ? ` (${backup.destination_label})` : ''; if (backup?.policy === 'required') return `Always created and verified first${where}`; @@ -205,6 +213,18 @@ export async function applyPlan({ api, store, scope, token, reviewAgain, notify return { job }; } +/** The details list's summary: "Listed records (N)", or "Listed files (N)" for a plan of files (S12); "(N of M)" when cut. */ +export function detailsSummary(plan, shown) { + const what = FILE_PLANS.has(plan?.operation) ? 'Listed files' : 'Listed records'; + const count = plan?.scope?.count ?? shown; + return shown < count ? `${what} (${number(shown)} of ${number(count)})` : `${what} (${number(shown)})`; +} + +/** D55's backup box for the review, or null for a plan that changes no library record (S12, `policy: "none"`). */ +export function backupBox(plan, onBackup) { + return noBackup(plan) ? null : { copy: BACKUP_COPY, onBackup }; +} + /** Show the reviewed `plan` for `operation`; Apply applies exactly this token. */ export function showPlan(plan, { api, store, scope, trigger = null, reviewAgain, notify = showWorkspaceToast, navigate = navigateTo, backup = () => downloadDatabaseBackup() }) { @@ -212,9 +232,7 @@ export function showPlan(plan, { api, store, scope, trigger = null, reviewAgain, const lines = detailLines(plan); if (lines.length) { const list = el('details'); - const count = plan.scope?.count ?? lines.length; - list.append(el('summary', lines.length < count ? `Listed records (${number(lines.length)} of ${number(count)})` - : `Listed records (${number(lines.length)})`)); + list.append(el('summary', detailsSummary(plan, lines.length))); const body = el('pre', lines.join('\n')); body.tabIndex = 0; list.append(body); @@ -238,7 +256,7 @@ export function showPlan(plan, { api, store, scope, trigger = null, reviewAgain, return; } openReviewDialog({ scope, trigger, title: PLAN_TITLE, heading, - grid: planGrid(plan), options, sentence: planNote(plan), backup: { copy: BACKUP_COPY, onBackup: backup }, + grid: planGrid(plan), options, sentence: planNote(plan), backup: backupBox(plan, backup), cancelLabel: 'Cancel', primary: { label: APPLY_LABEL }, onStart: () => applyPlan({ api, store, scope, token: plan.token, reviewAgain, notify, navigate }) }); } diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index 7e837cc4..e53684df 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -19,8 +19,8 @@ export const routes = [ // The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card. { id: 'activity', label: 'Activity' }, // Native System (#249 System S2): `#/system/
`. Q12: Overview's card opens its Overview section (`native`: - // a workspace hash, not a legacy page); each undelivered section hands off to its legacy tool (D94). The - // description is the concept's System heading (app.js renderSystem 700). + // a workspace hash, not a legacy page); every section is native (S12 retired D94's hand-off). The description is + // the concept's System heading (app.js renderSystem 700). { id: 'system', label: 'System', href: '#/system/overview', native: true, description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' }, ]; diff --git a/web/js/workspace/system-diagnostics.mjs b/web/js/workspace/system-diagnostics.mjs new file mode 100644 index 00000000..40382ae0 --- /dev/null +++ b/web/js/workspace/system-diagnostics.mjs @@ -0,0 +1,600 @@ +/** + * System › Diagnostics & logs (#249 System S12, #329): the concept's `renderSystem` "diagnostics" body (app.js 694–695) + * with live data, built with the DOM. + * + * - "Diagnostics": "Run checks" opens the shared Health report (D95), as Overview and Maintenance do. The concept's + * `div.meta-grid` reads one `GET /system/diagnostics` (Database, Sources, Viewer.js) and the send bridge (Host + * renderers, D106's line). "Download diagnostic report" saves a fresh report (the server's listed fields, no secret, + * no absolute path) with the renderer line added. "Image-link diagnostics" → Inspect opens the read-only Inspect a + * file page. D110 adds "Test image link": the legacy synthetic link, confirmed first, naming the prompt it links. + * - "Logs": the concept's toolbar (Level, Refresh, Download log) over the in-memory buffer, newest first, as + * `HH:MM:SS LEVEL message`. D109 adds: Lines 1–500, all six minimum levels, the statistics line, CRITICAL as a + * runtime level, the descriptions that say a runtime value lasts until the server restarts, the retention line, the + * log files with their downloads and "Clear all logs" (confirmed). "Runtime log level" and "Console logging" are + * runtime drafts saved by the one Save row (`POST /logs/config`). "Review log cleanup" reviews the `rotated_logs` + * maintenance plan (D100), which deletes only the rotated copies it listed. + * + * Every request goes through the page's api client; every listener sits on the section's `scope`, and a read that + * answers after the section was left paints nothing. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { settingRow, operationRow } from './system-controls.mjs'; +import { runHealthAction } from './system-overview.mjs'; +import { reviewMissingLinks } from './system-maintenance.mjs'; +import { bridgeFactory, rendererText, RENDERER_COPY } from './system-integrations.mjs'; +import { closeWorkspaceDialog, openContentDialog } from './workspace-dialogs.mjs'; +import { reviewPlan } from './maintenance-review.mjs'; +import { backupFilename, saveResponse } from './activity-scan.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { navigateTo } from './state.mjs'; +import { noun } from './plural.mjs'; + +export const REPORT_URL = '/prompt_manager/system/diagnostics'; +export const RECENT_URL = '/prompt_manager/recent?limit=1'; +export const TEST_LINK_URL = '/prompt_manager/diagnostics/test-link'; +export const LOGS_URL = '/prompt_manager/logs'; +export const STATS_URL = '/prompt_manager/logs/stats'; +export const FILES_URL = '/prompt_manager/logs/files'; +export const CONFIG_URL = '/prompt_manager/logs/config'; +export const TRUNCATE_URL = '/prompt_manager/logs/truncate'; +export const DOWNLOAD_URL = '/prompt_manager/logs/download/'; +export const ACTIVE_LOG = 'prompt_manager.log'; +export const TEST_LINK_FILE = 'prompt_manager_test_link.png'; + +export const LOADING = 'Loading…'; +export const UNREADABLE = 'Could not be read.'; +export const META_TERMS = Object.freeze(['Database', 'Sources', 'Viewer.js', 'Host renderers']); +/** S12-8: a minimum level; `''` asks for every level. */ +export const LEVEL_OPTIONS = Object.freeze([['', 'All levels'], ['DEBUG', 'DEBUG'], ['INFO', 'INFO'], ['WARNING', 'WARNING'], + ['ERROR', 'ERROR'], ['CRITICAL', 'CRITICAL']]); +/** The concept's runtime levels in its order, then CRITICAL (D109). */ +export const RUNTIME_LEVELS = Object.freeze(['INFO', 'WARNING', 'DEBUG', 'ERROR', 'CRITICAL']); +export const DEFAULT_LIMIT = 100; +export const MAX_LIMIT = 500; +export const KIND_LABELS = Object.freeze({ active: 'Active log', rotated: 'Rotated copy', link: 'Link, not downloaded', + other: 'Other file' }); + +export const COPY = Object.freeze({ + runtimeLevel: 'How much the server writes to the log. Applied until the server restarts.', + console: 'Print Prompt Manager’s warnings and errors in the ComfyUI console (everything at DEBUG). Applied until the server restarts.', + inspect: 'Check a file-to-prompt association without changing it.', + testLink: 'Write a placeholder image record to check that linking works.', + noPrompt: 'Add a prompt first.', + promptUnread: 'The most recent prompt could not be read.', + noEntries: 'No log entries at this level.', + logUnread: 'The log could not be read.', + statsUnread: 'The log statistics could not be read.', + filesUnread: 'The log files could not be read.', + configUnread: 'The retention settings could not be read.', + runtimeUnread: 'The current value could not be read.', + limitHint: 'Lines to show: a whole number from 1 to 500.', + logDownloadFailed: 'The log could not be downloaded.', + reportDone: 'Diagnostic report downloaded.', + reportFailed: 'The diagnostic report could not be downloaded. Nothing was saved to this device.', + testLinkFailed: 'The test link could not be created. Nothing was changed.', + clearFailed: 'The logs could not be cleared. Read the log files again before retrying.', + clearDescription: 'Empty the active log and delete its rotated copies.', +}); + +const number = value => value.toLocaleString('en-US'); + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** A byte count as the server's plan labels it: `1000 bytes`, `2.0 KB`, `10.0 MB`, `1.6 GB` (1 KB = 1024 bytes). */ +export function sizeText(bytes) { + const size = Number.isFinite(bytes) && bytes > 0 ? Math.floor(bytes) : 0; + if (size < 1024) return `${number(size)} ${noun(size, 'byte')}`; + for (const [unit, scale] of [['KB', 2 ** 10], ['MB', 2 ** 20]]) { + if (size < scale * 1024) return `${(size / scale).toFixed(1)} ${unit}`; + } + return `${(size / 2 ** 30).toFixed(1)} GB`; +} + +/** The text for a read that was refused: a `busy` refusal names the running job (D57's naming). */ +export function refusalText(error) { + if (error?.code === 'busy') return `Busy: ${error.running?.title || 'another job'} is running.`; + return UNREADABLE; +} + +/** A `dd` text from a read's state: `{loading}`, `{error}` or `{report}` handed to `value(report)`. */ +function stateText(state, value) { + if (!state || state.loading) return LOADING; + if (state.error !== undefined) return refusalText(state.error); + return value(state.report); +} + +/** Database: "Readable · 1.6 GB · 19,956 images", or why it is not readable. */ +export function databaseText(state) { + return stateText(state, report => { + const check = report?.checks?.database; + if (check?.status !== 'ok') return `Not readable: ${check?.message || 'no answer from the database'}`; + const images = Number.isSafeInteger(check.image_count) ? check.image_count : 0; + return `Readable · ${sizeText(report.database_info?.database_size_bytes)} · ${number(images)} ${noun(images, 'image')}`; + }); +} + +/** Sources: the first match of the design's precedence table (§4). */ +export function sourcesText(state) { + return stateText(state, report => { + const count = key => (Number.isSafeInteger(report?.sources?.[key]) ? report.sources[key] : 0); + const configured = count('configured'); + const watching = count('watching'); + const unavailable = count('unavailable'); + if (configured === 0) return `ComfyUI output folder · ${watching ? 'watching' : 'manual scan'}`; + if (unavailable > 0) return `${number(configured)} ${noun(configured, 'source')} · ${number(unavailable)} not available`; + return `${number(configured)} ${noun(configured, 'source')} · ${number(watching)} watching`; + }); +} + +/** Viewer.js: the pinned vendor version the server ships. */ +export function viewerText(state) { + return stateText(state, report => `${report?.viewerjs || 'unknown'} · pinned vendor`); +} + +const pad = value => String(value).padStart(2, '0'); + +/** `prompt-manager-diagnostics-YYYYMMDD-HHMM.json` in local time. */ +export function reportFilename(now = new Date()) { + const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`; + return `prompt-manager-diagnostics-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.json`; +} + +/** The report as saved: the server's fields and the renderer line this page saw (null while it looks). */ +export function mergeReport(report, hostRenderers) { + return { ...report, host_renderers: typeof hostRenderers === 'string' ? hostRenderers : null }; +} + +/** One buffer entry as the concept prints it: `09:42:18 INFO message`. */ +export function logLine(entry) { + const time = typeof entry?.timestamp === 'string' ? entry.timestamp.slice(11, 19) : '--:--:--'; + return `${time} ${String(entry?.level ?? '').padEnd(5)} ${entry?.message ?? ''}`; +} + +/** Lines: a whole number 1–500 after trimming, else null. */ +export function validLimit(value) { + const text = String(value ?? '').trim(); + if (!/^\d+$/.test(text)) return null; + const limit = Number(text); + return limit >= 1 && limit <= MAX_LIMIT ? limit : null; +} + +/** The `/logs` request for a level (`''`: every level) and a valid limit. */ +export function logsUrl(level, limit) { + const query = new URLSearchParams({ limit: String(limit) }); + if (level) query.set('level', level); + return `${LOGS_URL}?${query}`; +} + +/** D109: "1,000 entries in memory · 3 errors · 12 warnings · 5 files · 41.2 MB on disk" (errors: ERROR + CRITICAL). */ +export function statsText(stats) { + const counts = stats?.level_counts ?? {}; + const of = key => (Number.isSafeInteger(counts[key]) ? counts[key] : 0); + const entries = Number.isSafeInteger(stats?.buffer_count) ? stats.buffer_count : 0; + const errors = of('ERROR') + of('CRITICAL'); + const warnings = of('WARNING'); + const files = Number.isSafeInteger(stats?.log_files_count) ? stats.log_files_count : 0; + return [`${number(entries)} ${noun(entries, 'entry', 'entries')} in memory`, `${number(errors)} ${noun(errors, 'error')}`, + `${number(warnings)} ${noun(warnings, 'warning')}`, `${number(files)} ${noun(files, 'file')}`, + `${sizeText(stats?.total_log_size)} on disk`].join(' · '); +} + +const megabytes = bytes => { + const value = (Number.isFinite(bytes) ? bytes : 0) / 2 ** 20; + return Number.isInteger(value) ? number(value) : value.toFixed(1); +}; + +/** D109: the read-only retention values. */ +export function retentionText(config) { + const copies = Number.isSafeInteger(config?.backup_count) ? config.backup_count : 0; + const buffer = Number.isSafeInteger(config?.buffer_size) ? config.buffer_size : 0; + return `Rotates at ${megabytes(config?.max_file_size)} MB · keeps ${number(copies)} rotated ${noun(copies, 'copy', 'copies')}` + + ` · file logging ${config?.file_logging === false ? 'off' : 'on'} · memory buffer ${number(buffer)} ${noun(buffer, 'entry', 'entries')}`; +} + +const rotatedOf = files => (Array.isArray(files) ? files : []).filter(row => row?.kind === 'rotated'); + +/** The concept's line under "Log files & retention": "prompt_manager.log · 1.4 MB · rotated copies: 3". */ +export function fileLine(files) { + const active = (Array.isArray(files) ? files : []).find(row => row?.kind === 'active'); + const size = active ? sizeText(active.size) : 'not written yet'; + return `${ACTIVE_LOG} · ${size} · rotated copies: ${number(rotatedOf(files).length)}`; +} + +/** "2026-10-25 14:02" from the server's naive local ISO time. */ +export function modifiedText(iso) { + return typeof iso === 'string' && iso.length >= 16 ? iso.slice(0, 16).replace('T', ' ') : 'unknown time'; +} + +/** One listed file's line: " · · ". */ +export function fileDetail(row) { + return `${sizeText(row?.size)} · ${modifiedText(row?.modified)} · ${KIND_LABELS[row?.kind] ?? KIND_LABELS.other}`; +} + +/** CUJ 7: what Clear all logs does, from the files read last (null: they could not be read). */ +export function clearSentence(files) { + const tail = 'and clears the lines shown here. The other files in the log folder are kept. This cannot be undone.'; + if (!Array.isArray(files)) return `Empties ${ACTIVE_LOG}, deletes its rotated copies ${tail}`; + const rotated = rotatedOf(files); + if (!rotated.length) return `Empties ${ACTIVE_LOG} ${tail}`; + const size = rotated.reduce((sum, row) => sum + (Number.isFinite(row.size) ? row.size : 0), 0); + return `Empties ${ACTIVE_LOG}, deletes ${number(rotated.length)} rotated ${noun(rotated.length, 'copy', 'copies')} (${sizeText(size)}) ${tail}`; +} + +/** CUJ 7: the toast for `/logs/truncate`'s `results` ({truncated: [names], errors: [messages]}). */ +export function clearToast(results) { + const truncated = Array.isArray(results?.truncated) ? results.truncated : []; + const deleted = truncated.filter(name => typeof name === 'string' && name.toLowerCase() !== ACTIVE_LOG).length; + const failed = Array.isArray(results?.errors) ? results.errors.length : 0; + const parts = ['Logs cleared', `${number(deleted)} rotated ${noun(deleted, 'copy', 'copies')} deleted`]; + if (failed) parts.push(`${number(failed)} could not be deleted`); + return `${parts.join(' · ')}.`; +} + +/** A prompt's short name for the test-link dialog: its first words, on one line. */ +export function promptName(prompt) { + const text = String(prompt?.text ?? '').replace(/\s+/g, ' ').trim(); + return text.length > 60 ? `${text.slice(0, 59)}…` : text; +} + +/** CUJ 8: the confirmation, naming the prompt the placeholder record links to. */ +export function testLinkSentence(prompt) { + return `Writes a placeholder image record (${TEST_LINK_FILE}) linked to prompt #${prompt.id} “${promptName(prompt)}”. ` + + 'Maintenance › Missing-file links removes it later.'; +} + +/** S12-9: the runtime drafts from `GET /logs/config`'s `config`. */ +export function runtimeValues(config) { + return { runtime_level: config?.level, console_logging: config?.console_logging }; +} + +/** S12-9: the `POST /logs/config` body for the changed runtime drafts only. */ +export function runtimeBody(changes) { + const body = {}; + if (Object.hasOwn(changes ?? {}, 'runtime_level')) body.level = changes.runtime_level; + if (Object.hasOwn(changes ?? {}, 'console_logging')) body.console_logging = changes.console_logging; + return body; +} + +/** Fetch `url` through the api-client URL and save it under the name the server gave (or `fallback`). */ +async function download(url, fallback, fetchUrl) { + const response = await fetchUrl(url); + if (!response.ok) throw new Error(`Download failed with ${response.status}`); + const blob = await response.blob(); + saveResponse(blob, backupFilename(response.headers.get('Content-Disposition'), fallback)); +} + +/** Run `work` with `button` aria-disabled; a second press meanwhile is ignored. */ +async function pressing(button, work) { + if (button.getAttribute('aria-disabled') === 'true') return; + button.setAttribute('aria-disabled', 'true'); + try { + await work(); + } finally { + button.removeAttribute('aria-disabled'); + } +} + +function confirmDialog({ scope, trigger, title, sentence, label, danger = false, onConfirm }) { + const cancel = createButton('Cancel'); + const confirm = createButton(label, danger ? 'danger' : 'ca-primary'); + cancel.addEventListener('click', () => closeWorkspaceDialog()); + confirm.addEventListener('click', () => { + closeWorkspaceDialog(); + onConfirm(); + }); + const actions = el('div', undefined, 'dialog-actions'); + actions.append(cancel, confirm); + openContentDialog({ scope, title, trigger, nodes: [el('p', sentence), actions] }); +} + +/** The "Test image link" operation (D110): disabled until the most recent prompt was read. */ +function testLinkRow({ api, scope, notify }) { + let prompt = null; + const row = operationRow({ scope, title: 'Test image link', description: COPY.testLink, label: 'Create test link…', + onClick: button => { + if (button.getAttribute('aria-disabled') === 'true' || !prompt) return; + const target = prompt; + // The button is held until the write answered, so a second press cannot write a second record. + confirmDialog({ scope, trigger: button, title: 'Create test link', sentence: testLinkSentence(target), label: 'Create', + onConfirm: () => pressing(button, async () => { + try { + const answer = await api.post(TEST_LINK_URL, { prompt_id: target.id }); + if (!scope.signal.aborted) notify(`Test link created (image record ${answer?.result?.image_id}).`); + } catch { + if (!scope.signal.aborted) notify(COPY.testLinkFailed); + } + }) }); + } }); + const button = row.querySelector('.row-actions > button'); + const description = row.querySelector('p'); + button.setAttribute('aria-disabled', 'true'); + api.get(RECENT_URL, { signal: scope.signal, cache: 'no-store' }).then(answer => { + if (scope.signal.aborted) return; + const first = Array.isArray(answer?.results) ? answer.results[0] : null; + if (first && first.id !== undefined && first.id !== null) { + prompt = first; + button.removeAttribute('aria-disabled'); + } else { + description.textContent = `${COPY.testLink} ${COPY.noPrompt}`; + } + }, () => { + if (!scope.signal.aborted) description.textContent = `${COPY.testLink} ${COPY.promptUnread}`; + }); + return row; +} + +/** The "Diagnostics" surface. */ +function diagnosticsSurface({ api, activity, scope, navigate, notify, clientFactory }) { + const surface = el('section', undefined, 'surface'); + const heading = el('div', undefined, 'panel-heading'); + const run = createButton('Run checks'); + heading.append(el('h2', 'Diagnostics'), run); + const grid = el('div', undefined, 'meta-grid'); + const cells = {}; + for (const term of META_TERMS) { + const entry = el('div'); + cells[term] = el('dd', LOADING); + entry.append(el('dt', term), cells[term]); + grid.append(entry); + } + const report = createButton('Download diagnostic report'); + const inspect = operationRow({ scope, title: 'Image-link diagnostics', description: COPY.inspect, label: 'Inspect', + onClick: () => navigate('#/inspect') }); + surface.append(heading, grid, report, inspect, testLinkRow({ api, scope, notify })); + + // D95: the same Health report as Overview and Maintenance; its "Review missing links" reviews a fresh plan. + const onReviewMissing = reviewMissingLinks({ api, store: activity, scope, notify, navigate }); + scope.listen(run, 'click', () => runHealthAction({ api, activity, scope, button: run, navigate, notify, onReviewMissing })); + + const paint = state => { + cells.Database.textContent = databaseText(state); + cells.Sources.textContent = sourcesText(state); + cells['Viewer.js'].textContent = viewerText(state); + }; + paint({ loading: true }); + api.get(REPORT_URL, { signal: scope.signal, cache: 'no-store' }) + .then(answer => { if (!scope.signal.aborted) paint({ report: answer }); }, + error => { if (!scope.signal.aborted) paint({ error }); }); + + // S12-13: the renderer line Integrations shows (D106), from the send bridge. + let renderers = null; + let client = null; + scope.cleanup(() => client?.dispose()); + (async () => { + let text; + try { + client = clientFactory(); + text = rendererText(await client.discover({ waitMs: 300 })); + } catch { + text = RENDERER_COPY.unavailable; + } + if (scope.signal.aborted) return; + renderers = text; + cells['Host renderers'].textContent = text; + })(); + + scope.listen(report, 'click', () => pressing(report, async () => { + try { + const answer = await api.get(REPORT_URL, { signal: scope.signal, cache: 'no-store' }); + const body = JSON.stringify(mergeReport(answer, renderers), null, 2); + saveResponse(new Blob([body], { type: 'application/json' }), reportFilename()); + if (!scope.signal.aborted) notify(COPY.reportDone); + } catch (error) { + if (scope.signal.aborted) return; + notify(error?.code === 'busy' ? `${refusalText(error)} Try again when it ends.` : COPY.reportFailed); + } + })); + return surface; +} + +/** The two runtime rows: drafts of the `runtime` domain (S12-9), saved by Save preferences. */ +function runtimeRows({ scope, drafts }) { + const level = settingRow({ id: 'runtime-level', label: 'Runtime log level', description: COPY.runtimeLevel, + control: { type: 'select', options: RUNTIME_LEVELS }, scope, + onChange: value => drafts.set('runtime', 'runtime_level', value) }); + const consoleRow = settingRow({ id: 'console-log', label: 'Console logging', description: COPY.console, + control: { type: 'toggle', checked: false }, scope, onChange: value => drafts.set('runtime', 'console_logging', value) }); + const select = level.querySelector('[data-setting="runtime-level"]'); + const toggle = consoleRow.querySelector('[data-setting="console-log"]'); + select.dataset.key = 'runtime_level'; + toggle.dataset.key = 'console_logging'; + const repaint = () => { + const value = drafts.get('runtime', 'runtime_level'); + select.disabled = typeof value !== 'string'; + if (typeof value === 'string') select.value = value; + const on = drafts.get('runtime', 'console_logging'); + toggle.disabled = typeof on !== 'boolean'; + if (typeof on === 'boolean') toggle.checked = on; + }; + scope.cleanup(drafts.subscribe(repaint)); + repaint(); + // A failed read leaves both controls disabled; each says why (the value the server holds is not known). + const notes = [level, consoleRow].map(row => { + const note = el('p', COPY.runtimeUnread, 'small'); + return () => row.children[0].append(note); + }); + const reload = () => drafts.load('runtime').then(() => {}, () => { + if (!scope.signal.aborted) for (const show of notes) show(); + }); + reload(); + return { rows: [level, consoleRow], reload }; +} + +/** The "Logs" surface. */ +function logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnly, STALE, fetchUrl }) { + const surface = el('section', undefined, 'surface'); + const toolbar = el('div', undefined, 'toolbar spaced-16'); + const levelLabel = el('label', 'Level', 'small'); + levelLabel.htmlFor = 'log-level'; + const level = el('select'); + level.id = 'log-level'; + for (const [value, label] of LEVEL_OPTIONS) { + const option = el('option', label); + option.value = value; + level.append(option); + } + const limitLabel = el('label', 'Lines', 'small'); + limitLabel.htmlFor = 'log-limit'; + const limit = el('input'); + limit.id = 'log-limit'; + limit.type = 'number'; + limit.min = '1'; + limit.max = String(MAX_LIMIT); + limit.step = '1'; + limit.value = String(DEFAULT_LIMIT); + limit.title = COPY.limitHint; + const refresh = createButton('Refresh'); + const downloadLog = createButton('Download log'); + toolbar.append(levelLabel, level, limitLabel, limit, refresh, downloadLog); + + const lines = el('pre'); + lines.id = 'log-lines'; + lines.tabIndex = 0; + const code = el('code', LOADING); + lines.append(code); + const stats = el('p', LOADING, 'small'); + stats.id = 'log-stats'; + stats.setAttribute('role', 'status'); + stats.setAttribute('aria-label', 'Log statistics'); + + const files = el('details'); + const line = el('p', LOADING, 'small'); + const review = createButton('Review log cleanup'); + const retention = el('p', LOADING, 'small'); + const list = el('div', undefined, 'operation-list'); + let listed = null; + // The confirmation names what Clear deletes now: the files are read again before it opens (a read that fails + // gives the general sentence, never an old count). + const clear = operationRow({ scope, title: 'Clear all logs', description: COPY.clearDescription, label: 'Clear all logs…', + danger: true, onClick: button => pressing(button, async () => { + await readFiles(); + if (scope.signal.aborted) return; + confirmDialog({ scope, trigger: button, title: 'Clear all logs', sentence: clearSentence(listed), label: 'Clear', + danger: true, onConfirm: clearLogs }); + }) }); + files.append(el('summary', 'Log files & retention'), line, review, retention, list, clear); + const runtime = runtimeRows({ scope, drafts }); + surface.append(el('h2', 'Logs'), toolbar, lines, stats, ...runtime.rows, files); + + const logsRead = latestOnly({ lifetime: scope.signal }); + async function readLogs() { + const count = validLimit(limit.value); + if (count === null) { + limit.setAttribute('aria-invalid', 'true'); + return; + } + limit.removeAttribute('aria-invalid'); + let answer; + try { + answer = await logsRead(signal => api.get(logsUrl(level.value, count), { signal, cache: 'no-store' })); + } catch { + if (!scope.signal.aborted) code.textContent = COPY.logUnread; + return; + } + if (answer === STALE || scope.signal.aborted) return; + const entries = Array.isArray(answer?.logs) ? answer.logs : []; + code.textContent = entries.length ? entries.map(logLine).join('\n') : COPY.noEntries; + } + + /** One latest-only read of `url` painting `paint(answer)`, or `failed()` when it could not be read. */ + const reader = (url, paint, failed) => { + const read = latestOnly({ lifetime: scope.signal }); + return async () => { + let answer; + try { + answer = await read(signal => api.get(url, { signal, cache: 'no-store' })); + } catch { + if (!scope.signal.aborted) failed(); + return; + } + if (answer !== STALE && !scope.signal.aborted) paint(answer); + }; + }; + const readStats = reader(STATS_URL, answer => { stats.textContent = statsText(answer?.stats); }, + () => { stats.textContent = COPY.statsUnread; }); + const readConfig = reader(CONFIG_URL, answer => { retention.textContent = retentionText(answer?.config); }, + () => { retention.textContent = COPY.configUnread; }); + const readFiles = reader(FILES_URL, answer => { + listed = Array.isArray(answer?.files) ? answer.files : []; + line.textContent = fileLine(listed); + list.replaceChildren(...listed.map(fileRow)); + }, () => { + listed = null; + line.textContent = COPY.filesUnread; + list.replaceChildren(); + }); + + function fileRow(row) { + const item = el('div', undefined, 'operation'); + const text = el('div'); + text.append(el('h3', row.filename), el('p', fileDetail(row))); + item.append(text); + if (row.kind !== 'link') { + const actions = el('div', undefined, 'row-actions'); + const button = createButton('Download'); + button.setAttribute('aria-label', `Download ${row.filename}`); + scope.listen(button, 'click', () => saveLog(button, row.filename)); + actions.append(button); + item.append(actions); + } + return item; + } + + function saveLog(button, name) { + return pressing(button, async () => { + try { + await download(`${DOWNLOAD_URL}${encodeURIComponent(name)}`, name, fetchUrl); + } catch { + if (!scope.signal.aborted) notify(COPY.logDownloadFailed); + } + }); + } + + function readAll() { + readLogs(); + readStats(); + readFiles(); + readConfig(); + } + + async function clearLogs() { + let answer; + try { + answer = await api.post(TRUNCATE_URL, {}); + } catch { + if (!scope.signal.aborted) notify(COPY.clearFailed); + } + if (scope.signal.aborted) return; + if (answer) notify(clearToast(answer.results)); + readAll(); + runtime.reload(); + } + + scope.listen(level, 'change', readLogs); + scope.listen(limit, 'change', readLogs); + scope.listen(refresh, 'click', () => { readLogs(); readStats(); readFiles(); }); + scope.listen(downloadLog, 'click', () => saveLog(downloadLog, ACTIVE_LOG)); + scope.listen(review, 'click', () => reviewPlan({ api, store: activity, scope, operation: 'rotated_logs', trigger: review, + notify, navigate })); + readAll(); + return surface; +} + +/** + * Mount Diagnostics & logs in `container` (System's `.settings-layout`); returns its two surfaces in concept order. + * `drafts` is the System draft store (its runtime domain holds the log level and console setting), `activity` the + * page's Activity store. + */ +export function mountDiagnostics(container, { api, activity, scope, drafts, navigate = navigateTo, notify = showWorkspaceToast, + clientFactory = bridgeFactory(), latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE, + fetchUrl = window.ApiClient.fetchUrl }) { + return [ + diagnosticsSurface({ api, activity, scope, navigate, notify, clientFactory }), + logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnly, STALE, fetchUrl }), + ]; +} diff --git a/web/js/workspace/system-integrations.mjs b/web/js/workspace/system-integrations.mjs index a75b21a4..2eacf170 100644 --- a/web/js/workspace/system-integrations.mjs +++ b/web/js/workspace/system-integrations.mjs @@ -345,7 +345,8 @@ function comfySurface({ scope, clientFactory }) { return surface; } -function bridgeFactory() { +/** The send bridge's client factory for this page's mount (Integrations and Diagnostics & logs, S12-13). */ +export function bridgeFactory() { const url = new URL(window.ApiClient.resolveUrl('/prompt_manager/'), window.location.href); return () => createBridgeClient({ namespace: url.pathname.slice(0, -'prompt_manager/'.length), origin: window.location.origin }); } diff --git a/web/js/workspace/system-maintenance.mjs b/web/js/workspace/system-maintenance.mjs index 742f7c65..db40cffe 100644 --- a/web/js/workspace/system-maintenance.mjs +++ b/web/js/workspace/system-maintenance.mjs @@ -134,14 +134,22 @@ function metadataCacheRow({ scope, notify, cache }) { return row; } +/** + * The Health report's "Review missing links" (D95): a fresh Missing-file links plan, never a cleanup, holding the pressed + * button while it is read. Maintenance and Diagnostics & logs (S12) pass it to `runHealthAction`. + */ +export function reviewMissingLinks({ api, store, scope, notify = showWorkspaceToast, navigate = navigateTo }) { + return (trigger, pressed = trigger) => reviewPlan({ api, store, scope, operation: 'missing_links', trigger, hold: pressed, + notify, navigate }); +} + /** * The Maintenance section's mount (`mountSystem`'s `sections.maintenance`): the notice and the two surfaces. `activity` * is the page's Activity store. */ export function mountMaintenance(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast, cache = ownership() }) { - const reviewMissing = (trigger, pressed = trigger) => reviewPlan({ api, store: activity, scope, operation: 'missing_links', - trigger, hold: pressed, notify, navigate }); + const reviewMissing = reviewMissingLinks({ api, store: activity, scope, notify, navigate }); const actions = { health: button => runHealthAction({ api, activity, scope, button, onReviewMissing: reviewMissing, notify }), duplicates: () => findDuplicates({ store: activity, scope, notify, navigate }), diff --git a/web/js/workspace/system.mjs b/web/js/workspace/system.mjs index a8a933c0..52f13b90 100644 --- a/web/js/workspace/system.mjs +++ b/web/js/workspace/system.mjs @@ -3,11 +3,10 @@ * Under the page heading it renders the section nav (`.section-nav`, one button per section, the shown one * `aria-pressed`; a press navigates to its hash), then `.settings-layout` with the section's body and the Save row * (`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), - * Auto-tagging (system-tagging.mjs), Maintenance (system-maintenance.mjs) and Backup & export (system-backups.mjs) are - * native. + * truthful and "Save preferences" is aria-disabled while nothing is unsaved. Every section is native: Overview + * (system-overview.mjs), Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs), Integrations + * (system-integrations.mjs), Auto-tagging (system-tagging.mjs), Maintenance (system-maintenance.mjs), Backup & export + * (system-backups.mjs) and Diagnostics & logs (system-diagnostics.mjs, S12, which retired the D94 hand-off panel). * * 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. @@ -22,21 +21,13 @@ import { mountSources } from './system-sources.mjs'; import { mountMaintenance } from './system-maintenance.mjs'; import { mountBackups } from './system-backups.mjs'; import { mountTagging } from './system-tagging.mjs'; +import { mountDiagnostics, runtimeBody, runtimeValues, CONFIG_URL as LOG_CONFIG_URL } from './system-diagnostics.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'; import { displayPreferences, readPreferences, reconcilePreferences, subscribePreferences, writePreferences } from './preferences.mjs'; const SETTINGS_URL = '/prompt_manager/settings'; -const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' }); -/** - * D94: where each section's capabilities live until its PR (the plan's legacy capability coverage): only Diagnostics & - * logs still hands off, to the admin page's Logs and Diagnostics tools. - */ -export const SECTION_HANDOFFS = Object.freeze({ - diagnostics: [ADMIN], -}); -export const NOT_DELIVERED = 'This setting is not available here yet.'; let drafts = null; // The Activity store of the System page shown last: a refused host save that does not name its job reads the list. @@ -49,10 +40,9 @@ async function runningJob(error) { return jobs.running()[0]; } -function el(tag, text, className) { +function el(tag, className) { const node = document.createElement(tag); - if (text !== undefined) node.textContent = text; - if (className) node.className = className; + node.className = className; return node; } @@ -70,18 +60,16 @@ export function settingRefusal(error, body) { return error; } -/** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */ -const undelivered = async () => { throw new Error(NOT_DELIVERED); }; - /** * The page session's drafts (Q10). The browser domain is this browser's display defaults (preferences.mjs): a write * says whether storage kept it, so the status never claims "Saved in this browser." for a change only this tab holds. - * The host domain reads and saves the server settings with their revision (#220). + * The host domain reads and saves the server settings with their revision (#220). The runtime domain is the running + * server's log level and console setting (S12-9, `/logs/config`): applied until the server restarts. */ export function systemDrafts(api) { drafts ??= createSystemDrafts({ readBrowser: () => readPreferences().values, - readRuntime: () => ({}), + readRuntime: async () => runtimeValues((await api.get(LOG_CONFIG_URL, { cache: 'no-store' })).config), writeBrowser: changes => { // Another page changed a drafted value since this tab read it, and no storage event told it yet: refuse, // so the store keeps the draft, flags it and reads the values again; the next Save is an explicit choice. @@ -90,7 +78,7 @@ export function systemDrafts(api) { if (conflicts.length) throw Object.assign(new Error('Settings changed elsewhere'), { code: 'settings-changed' }); return { persisted: writePreferences(changes, undefined, { baseline }).persisted }; }, - writeRuntime: undelivered, + writeRuntime: changes => api.post(LOG_CONFIG_URL, runtimeBody(changes)), async readHost() { // S10: the LoRA state rides along; when it cannot be read its values stay missing (Integrations says so) // and every other host setting still loads. @@ -118,19 +106,6 @@ export function systemDrafts(api) { return drafts; } -/** D94: the shared hand-off panel, linking each legacy tool that still owns this section. */ -function handoff(section) { - const panel = el('section', undefined, 'surface handoff'); - panel.append(el('h2', 'Your current tools are ready'), - el('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.')); - for (const tool of SECTION_HANDOFFS[section]) { - const link = el('a', tool.label, 'workspace-link'); - link.href = window.ApiClient.resolveUrl(tool.href); - panel.append(link); - } - return [panel]; -} - /** D92: highlight the controls of the settings the last save could not save. */ function markFailed(layout, store) { const failed = new Set(DRAFT_DOMAINS.flatMap(domain => [...store.failed(domain)])); @@ -142,8 +117,9 @@ 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), Auto-tagging (S11 Task 6, #328), Maintenance (S7 Task 19) and Backup & export (S9 Task 24). + * Every System section: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11), Integrations (S10 + * Task 27), Auto-tagging (S11 Task 6, #328), Maintenance (S7 Task 19), Backup & export (S9 Task 24) and Diagnostics & + * logs (S12, #329). */ export const DELIVERED_SECTIONS = Object.freeze({ overview: mountOverview, @@ -153,13 +129,13 @@ export const DELIVERED_SECTIONS = Object.freeze({ tagging: mountTagging, maintenance: mountMaintenance, backups: mountBackups, + diagnostics: mountDiagnostics, }); /** * Mount System under the heading `renderHeading` wrote in `main`. Returns `{show(section)}`, which shows another * section in place. `scope` is the page's view scope; `sections` maps a section id to a delivered section's mount - * (`(container, {api, activity, scope, drafts}) => nodes`), and every other section hands off. `activity` is the page's - * Activity store, which follows the jobs a section starts. + * (`(container, {api, activity, scope, drafts}) => nodes`) for every section. `activity` is the page's Activity store, which follows the jobs a section starts. * * The host values are read on mount (and by the store again after a conflict), so a save carries the current revision; * until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on @@ -184,9 +160,9 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE scope.cleanup(subscribePreferences(window, next => reconcile(next.values))); const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope, onSelect: id => navigate(`#/system/${id}`) }); - const layout = el('div', undefined, 'settings-layout'); - const saveRow = el('div', undefined, 'save-row'); - const status = el('span', undefined, 'small'); + const layout = el('div', 'settings-layout'); + const saveRow = el('div', 'save-row'); + const status = el('span', 'small'); status.id = 'settings-status'; const save = createButton('Save preferences', 'ca-primary'); saveRow.append(status, save); @@ -214,8 +190,8 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE sectionScope = createScope(); for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next)); while (layout.firstChild !== saveRow) layout.firstChild.remove(); - const mount = sections[next]; - const nodes = mount ? mount(layout, { api, activity, scope: sectionScope, drafts: store }) : handoff(next); + // Every section is delivered (S12), and the router only names known sections (state.mjs). + const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store }); layout.prepend(...nodes); paintStatus(); } diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index efd4effb..95bc9e79 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -85,9 +85,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .tool-card { border: 1px solid var(--line); background: var(--ca-field); border-radius: 8px; padding: 20px; display: flex; flex-direction: column; gap: 10px; } .tool-card p { flex: 1; margin: 0 0 12px; } .workspace-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 0; font-size: 12px; overflow-wrap: anywhere; } -.handoff .workspace-link { margin-top: 12px; } -/* D94: a System section not delivered yet links every legacy tool that still owns it, side by side. */ -.handoff .workspace-link + .workspace-link { margin-inline-start: 18px; /* D94 */ } /* D92: System's Save row is aria-disabled while nothing is unsaved (or a save runs), and a setting the last save could not save is highlighted, as its "Review the highlighted settings" status says. */ @@ -439,3 +436,8 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc #workspace-main .setting-row:has(> #tag-model) { flex-wrap: wrap; /* D132 */ } #workspace-main .setting-row #tag-model { flex-shrink: 0; max-width: 100% !important; /* D132 */ } } + +/* D109: System › Diagnostics & logs' added Lines field sits in the concept's toolbar like its Level select, and a value + outside 1–500 is highlighted as a refused setting is (D92). */ +.toolbar #log-limit { inline-size: 6em; min-height: 36px; font-size: 11px; /* D109 */ } +.toolbar #log-limit[aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D109 */ }