Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14c8b2668 | ||
|
|
91cdb23029 | ||
|
|
be4ea95ec4 | ||
|
|
547bc578b9 |
+105
-6
@@ -2658,7 +2658,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
|
||||
@@ -2690,10 +2691,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
|
||||
@@ -2724,7 +2721,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
|
||||
@@ -2845,6 +2842,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
|
||||
@@ -3122,6 +3124,55 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
|
||||
`tag output images review matches the concept`, its mutation check and `the
|
||||
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
|
||||
the batch tests of workspace-system-tagging.spec.
|
||||
- **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 · <size> · 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`
|
||||
"<size> · <YYYY-MM-DD HH:MM> · Active log | Rotated copy | Other file | Link, not
|
||||
downloaded", "Download" named "Download <name>" 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 ·
|
||||
<size> · N images" or "Not readable: <message>"; Sources "N sources · M watching", "N
|
||||
sources · K not available", or with none configured "ComfyUI output folder · watching
|
||||
| manual scan"; Viewer.js "<pinned version> · pinned vendor". Host renderers is D106's
|
||||
renderer line from the send bridge. Each reads "Loading…" first, "Busy: <job> 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: <job> 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
|
||||
@@ -3701,6 +3752,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.<n>` (`<n>` 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 · <size>"), 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 `<server root>/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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -45,6 +45,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 = {
|
||||
@@ -199,6 +203,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
|
||||
|
||||
@@ -223,6 +229,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)
|
||||
|
||||
@@ -415,6 +422,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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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/<section>`, 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/<section>`, 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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""System › Diagnostics & logs test hooks for the browser tests (#249 System S12).
|
||||
|
||||
The logger writes into ``<server root>/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.<n>`` of the listed sizes (fixed
|
||||
times), on POSIX a symlink ``prompt_manager.log.<n+1>`` 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 ``<root>/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
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>([^<]+)<\/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({}), {});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+29
-23
@@ -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": {
|
||||
|
||||
@@ -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 }) });
|
||||
}
|
||||
|
||||
@@ -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/<section>`. 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' },
|
||||
];
|
||||
|
||||
@@ -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: "<size> · <modified> · <kind>". */
|
||||
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 }),
|
||||
];
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
+21
-45
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user