* feat(system): read-only health check and truthful overview counts POST /prompt_manager/system/health runs the health check off-loop as a read registry job of kind "health" (one at a time, beside a scan, not cancellable, no file outcomes) and keeps the last report; GET returns it or null. The check reads one snapshot on its own mode=ro connection, closed in finally: integrity_check(100), foreign_key_check, stored hashes against utils.hashing, duplicate pair rows, the legacy consistency queries (now shared with check_consistency), and image links whose file is gone, counted only under readable sources. An unavailable source, or the absent Needs-review queue, makes its check unavailable and the report incomplete instead of producing a count. GET /prompt_manager/system/overview returns Library-eligible positive prompts and active negative variants, indexed images, the last health summary or null, backup null, the Needs-review count null, the effective sources with whether they were auto-detected, and the thumbnail plan's missing count. GET /prompt_manager/system/statistics lists runs and average rating per category of Library parents, with the /stats totals. * feat(system): native System route, drafts and shared review dialog - #/system/<section> renders the concept's System shell: section nav, settings layout and Save row with truthful status (D92); each section not delivered yet hands off to its legacy tools (D94). Overview's System card opens #/system/overview (Q12). - A module-level draft store saves browser, then host (expected_revision), then runtime; failed domains stay dirty, a stale revision is a conflict. - System row builders (setting, operation, section nav) on the scope. - openReviewDialog and backupNotice extracted from the scan review, which is now a thin caller with identical markup. - Overview images gain a "rated" count (images whose linked prompt is rated); the missing-files health check reports per source, so partial availability is visible. * feat(system): native Overview with health report and statistics System > Overview renders the concept's metrics and its Library health and Workspace preferences surfaces from GET /system/overview: real counts, "Not checked" until a health check ran, no invented review count, backup or thumbnail status (D93). Explore opens "Usage statistics" with the real runs and ratings per category and the Library totals (D96); the statistics route now also returns rated_images. Maintenance shows the concept's notice with Run health checks above its hand-off: the read-only check runs as a registry read job and opens the "Health report" with a chip per real result, missing files per source (D95). A load failure shows Retry and no zeros; late answers never repaint a newer section. Adds the System concept comparison (shell, Overview, both dialogs, the notice, mutation checks), the System workflow spec and its fixture hooks, and the TESTING.md "Native System" section. * test(system): coverage floors for the native Overview Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes. * fix(system): keep health reports per database and off the event loop - A restore that replaces the database forgets the stored health report, and a check that started before the swap is not kept (database generation stamped at start). - The Overview no longer walks every source for the missing-thumbnail count; the page reads /images/thumbnails/status on its own. - Image sources for the health check and the Overview resolve in the executor; a failure to start the check answers the coded envelope. - Any exception fails only its own check; missing files reads its rows in the snapshot and stats the files after it ended. - The read-only URI keeps Windows UNC paths (empty authority). - An integrity count at SQLite's limit is flagged as capped. * fix(system): thumbnail row read, tracked health job and guarded host save - The Overview's thumbnail row is its own read of the thumbnail status: "Checking thumbnails…" until it answers, unavailable when it fails. - The health job is tracked in the Activity store (new store.track) and followed through its event stream instead of polling every 150 ms. - A host save before a read returned a revision fails without a request; System reads the host values on mount and after a conflict. - "100+ problems found" when the integrity count is capped. - D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone. - The concept spec builds the health report from the concept's own rows and asserts the full unmapped path list; surface tops pin the 18px offset. - TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI and the new behavior. Coverage floors raised for the touched files. * fix(system): make two health tests portable to Windows - Windows forbids "?" in file names, so the URI-character open test uses "#", "%" and a space there. - ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of raising, so the bad-path test raises as POSIX does to reach the missing-files failure path on every platform.
256 lines
14 KiB
JavaScript
256 lines
14 KiB
JavaScript
// System › Overview (#249 System S2 Task 6): the concept's Overview copy on real values (D93), the Health report rows
|
||
// (D95), the Statistics tables (D96) and the health check run that waits for its read job.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const overviewModule = import('../../web/js/workspace/system-overview.mjs');
|
||
|
||
const OVERVIEW = Object.freeze({
|
||
library: { positive_prompts: 1234, negative_variants: 7 },
|
||
images: { indexed: 24, rated: 20 },
|
||
health: null,
|
||
backup: null,
|
||
needs_review: { pending: null },
|
||
sources: { configured: 2, effective: 2, auto_detected: false },
|
||
});
|
||
|
||
test('metrics are the server counts and Not checked before any health check', async () => {
|
||
const { metricValues } = await overviewModule;
|
||
assert.deepEqual(metricValues(OVERVIEW), [
|
||
[(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'],
|
||
['Not checked', 'Database integrity'],
|
||
]);
|
||
});
|
||
|
||
test('metric 4 is the last health result', async () => {
|
||
const { integrityText } = await overviewModule;
|
||
assert.equal(integrityText(null), 'Not checked');
|
||
assert.equal(integrityText({ status: 'passed', issues: 0 }), 'Passed');
|
||
assert.equal(integrityText({ status: 'issues', issues: 1 }), '1 issue');
|
||
assert.equal(integrityText({ status: 'issues', issues: 3 }), '3 issues');
|
||
assert.equal(integrityText({ status: 'incomplete', issues: 0 }), 'Incomplete');
|
||
// Issues found beside a check that could not run (Needs review, Q6) are still named.
|
||
assert.equal(integrityText({ status: 'incomplete', issues: 2 }), '2 issues');
|
||
});
|
||
|
||
test('library health rows never invent a review count, a backup or thumbnails', async () => {
|
||
const { ambiguousTitle, backupText } = await overviewModule;
|
||
assert.equal(ambiguousTitle({ pending: null }), 'Ambiguous image links');
|
||
assert.equal(ambiguousTitle(undefined), 'Ambiguous image links');
|
||
assert.equal(ambiguousTitle({ pending: 1 }), '1 ambiguous image link');
|
||
assert.equal(ambiguousTitle({ pending: 3 }), '3 ambiguous image links');
|
||
assert.equal(backupText(null), 'No retained backup record.');
|
||
assert.equal(backupText({ verified_at: 'not a time' }), 'No retained backup record.');
|
||
assert.match(backupText({ verified_at: '2026-10-22T09:30:00Z' }), /^Last verified backup .+\.$/);
|
||
});
|
||
|
||
test('the thumbnail row is its own read of the thumbnail status', async () => {
|
||
const { thumbnailTitle, THUMBNAILS_LOADING } = await overviewModule;
|
||
// Until `/images/thumbnails/status` answers the row says it is checking; a failed read is unavailable, never 0.
|
||
assert.equal(THUMBNAILS_LOADING, 'Checking thumbnails…');
|
||
assert.equal(thumbnailTitle(undefined), THUMBNAILS_LOADING);
|
||
assert.equal(thumbnailTitle({ pending: 0 }), 'No missing thumbnails');
|
||
assert.equal(thumbnailTitle({ pending: 1 }), '1 missing thumbnail');
|
||
assert.equal(thumbnailTitle({ pending: 12 }), '12 missing thumbnails');
|
||
assert.equal(thumbnailTitle(null), 'Thumbnail status unavailable');
|
||
assert.equal(thumbnailTitle({ pending: 'x' }), 'Thumbnail status unavailable');
|
||
// With no image source there is nothing to count.
|
||
assert.equal(thumbnailTitle({ pending: 0 }, { effective: 0 }), 'Thumbnail status unavailable');
|
||
assert.equal(thumbnailTitle({ pending: 2 }, { effective: 1 }), '2 missing thumbnails');
|
||
});
|
||
|
||
test('an integrity count SQLite stopped at reads as a lower bound', async () => {
|
||
const { checkChip } = await overviewModule;
|
||
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 100, capped: true }), '100+ problems found');
|
||
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 37, capped: false }), '37 problems found');
|
||
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 1 }), '1 problem found');
|
||
});
|
||
|
||
test('the sources row tells configured, available and detected sources apart', async () => {
|
||
const { sourcesCopy } = await overviewModule;
|
||
const shared = 'Monitoring and enrichment share one source list.';
|
||
assert.deepEqual(sourcesCopy({ configured: 2, effective: 2, auto_detected: false }), ['2 output sources', shared]);
|
||
assert.deepEqual(sourcesCopy({ configured: 2, effective: 1, auto_detected: false }),
|
||
['1 output source', `1 of 2 configured sources available. ${shared}`]);
|
||
assert.deepEqual(sourcesCopy({ configured: 0, effective: 1, auto_detected: true }),
|
||
['1 output source', `None configured is available; ComfyUI's output folder is used. ${shared}`]);
|
||
});
|
||
|
||
const REPORT = Object.freeze({
|
||
checked_at: '2026-10-23T08:00:00Z',
|
||
status: 'issues',
|
||
checks: [
|
||
{ id: 'integrity', label: 'Integrity check', status: 'passed', count: 0 },
|
||
{ id: 'foreign_keys', label: 'Foreign keys & pair references', status: 'issues', count: 2 },
|
||
{ id: 'canonical_hashes', label: 'Canonical hashes', status: 'issues', count: 1 },
|
||
{ id: 'pair_uniqueness', label: 'Pair uniqueness', status: 'failed', count: null },
|
||
{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 2,
|
||
sources: [{ name: 'output', status: 'checked', count: 2 }] },
|
||
{ id: 'ambiguous_metadata', label: 'Ambiguous metadata', status: 'unavailable', count: null },
|
||
{ id: 'consistency', label: 'Consistency', status: 'issues', count: null },
|
||
],
|
||
});
|
||
|
||
test('health rows give each check a chip from its real status, in the server order', async () => {
|
||
const { healthRows } = await overviewModule;
|
||
assert.deepEqual(healthRows(REPORT), [
|
||
['Integrity check', 'Passed'],
|
||
['Foreign keys & pair references', '2 broken references'],
|
||
['Canonical hashes', '1 hash does not match its text'],
|
||
['Pair uniqueness', 'Could not check'],
|
||
['Missing files', '2 links need review'],
|
||
['Ambiguous metadata', 'Not available yet'],
|
||
['Consistency', 'Issues found'],
|
||
]);
|
||
});
|
||
|
||
test('missing files is reported per image source when there are several', async () => {
|
||
const { healthRows } = await overviewModule;
|
||
const report = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'issues', count: 1, partial: true,
|
||
sources: [{ name: 'output', status: 'checked', count: 1 }, { name: 'input', status: 'checked', count: 0 },
|
||
{ name: 'archive', status: 'unavailable', count: null }] }] };
|
||
assert.deepEqual(healthRows(report), [
|
||
['Missing files · output', '1 link needs review'],
|
||
['Missing files · input', 'Passed'],
|
||
['Missing files · archive', 'Not available'],
|
||
]);
|
||
const unavailable = { ...REPORT, checks: [{ id: 'missing_files', label: 'Missing files', status: 'unavailable', count: null, sources: [] }] };
|
||
assert.deepEqual(healthRows(unavailable), [['Missing files', 'Not available']]);
|
||
});
|
||
|
||
test('the report summary says when it was checked and whether it is complete', async () => {
|
||
const { healthSummary } = await overviewModule;
|
||
const when = new Date(REPORT.checked_at).toLocaleString();
|
||
assert.equal(healthSummary({ ...REPORT, status: 'passed' }), `Checked ${when}. Every check passed.`);
|
||
assert.equal(healthSummary({ ...REPORT, status: 'incomplete' }), `Checked ${when}. Some checks could not run, so this report is incomplete.`);
|
||
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
|
||
});
|
||
|
||
test('statistics rows are the real runs and ratings, then the library totals', async () => {
|
||
const { categoryRows, totalsRows } = await overviewModule;
|
||
const statistics = {
|
||
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
|
||
{ category: null, runs: 3, average_rating: null }],
|
||
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
|
||
rated_images: 20,
|
||
};
|
||
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
|
||
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
|
||
});
|
||
|
||
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
|
||
function fakeApi(answers) {
|
||
const calls = [];
|
||
const answer = (method, url) => {
|
||
calls.push(`${method} ${url}`);
|
||
const queue = answers[`${method} ${url}`];
|
||
if (!queue?.length) throw new Error(`unexpected ${method} ${url}`);
|
||
const next = queue.shift();
|
||
return next instanceof Error ? Promise.reject(next) : Promise.resolve(next);
|
||
};
|
||
return { calls, get: url => answer('GET', url), post: url => answer('POST', url) };
|
||
}
|
||
|
||
const JOB = '0123456789abcdef0123456789abcdef';
|
||
const job = (state, id = JOB, kind = 'health') => ({ id, kind, state });
|
||
|
||
/**
|
||
* A fake Activity store holding `jobs`: `track(job)` lists a started job, `load()` applies the next of `reads` (the
|
||
* server's job lists) and `push(job)` is a stream update; each notifies as the real store does. It never polls.
|
||
*/
|
||
function fakeActivity({ jobs = [], reads = [] } = {}) {
|
||
const listeners = new Set();
|
||
const emit = () => { for (const listener of [...listeners]) listener(); };
|
||
const store = {
|
||
loads: 0,
|
||
find: id => jobs.find(item => item.id === id),
|
||
running: () => jobs.filter(item => item.state === 'running'),
|
||
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
||
listeners: () => listeners.size,
|
||
async load() {
|
||
store.loads += 1;
|
||
if (reads.length) jobs = reads.shift();
|
||
emit();
|
||
},
|
||
track(item) {
|
||
if (!item?.id) throw new TypeError('Invalid job');
|
||
store.push(item);
|
||
return item;
|
||
},
|
||
push(item) {
|
||
jobs = [...jobs.filter(other => other.id !== item.id), item];
|
||
emit();
|
||
},
|
||
};
|
||
return store;
|
||
}
|
||
|
||
test('a health check tracks its job in the Activity store, then reads the report', async () => {
|
||
const { runHealthCheck } = await overviewModule;
|
||
const api = fakeApi({
|
||
'POST /prompt_manager/system/health': [{ job: job('running') }],
|
||
'GET /prompt_manager/system/health': [{ report: REPORT }],
|
||
});
|
||
const activity = fakeActivity();
|
||
const checking = runHealthCheck(api, { activity });
|
||
await new Promise(resolve => setImmediate(resolve));
|
||
// The store follows the started job's event stream; nothing reads or polls the job here.
|
||
assert.equal(activity.loads, 0);
|
||
assert.equal(activity.find(JOB).state, 'running');
|
||
assert.deepEqual(api.calls, ['POST /prompt_manager/system/health']);
|
||
activity.push(job('finished'));
|
||
assert.equal(await checking, REPORT);
|
||
assert.deepEqual(api.calls, ['POST /prompt_manager/system/health', 'GET /prompt_manager/system/health']);
|
||
assert.equal(activity.listeners(), 0);
|
||
});
|
||
|
||
test('a check already running elsewhere is waited for, not refused', async () => {
|
||
const { runHealthCheck } = await overviewModule;
|
||
const busy = Object.assign(new Error('busy'), { status: 409, code: 'busy' });
|
||
const api = fakeApi({
|
||
'POST /prompt_manager/system/health': [busy],
|
||
'GET /prompt_manager/system/health': [{ report: REPORT }],
|
||
});
|
||
const activity = fakeActivity({ reads: [[job('running', 'f'.repeat(32), 'scan'), job('running')]] });
|
||
const checking = runHealthCheck(api, { activity });
|
||
await new Promise(resolve => setImmediate(resolve));
|
||
assert.equal(activity.loads, 1);
|
||
activity.push(job('finished'));
|
||
assert.equal(await checking, REPORT);
|
||
// The other check ended between the refusal and the read: its report is the latest.
|
||
const ended = fakeApi({ 'POST /prompt_manager/system/health': [busy], 'GET /prompt_manager/system/health': [{ report: REPORT }] });
|
||
assert.equal(await runHealthCheck(ended, { activity: fakeActivity({ reads: [[]] }) }), REPORT);
|
||
});
|
||
|
||
test('a failed, lost or reportless check rejects; other errors pass through', async () => {
|
||
const { runHealthCheck, waitForJob, HEALTH_FAILED_TEXT } = await overviewModule;
|
||
const failed = fakeApi({ 'POST /prompt_manager/system/health': [{ job: job('failed') }] });
|
||
await assert.rejects(runHealthCheck(failed, { activity: fakeActivity() }), { message: HEALTH_FAILED_TEXT });
|
||
// The store marks a job lost when the server no longer lists it (ComfyUI restarted).
|
||
const restarted = fakeActivity({ jobs: [job('running')] });
|
||
const waiting = waitForJob(restarted, JOB);
|
||
restarted.push(job('lost'));
|
||
assert.equal(await waiting, 'lost');
|
||
const empty = fakeApi({ 'POST /prompt_manager/system/health': [{ job: job('finished') }],
|
||
'GET /prompt_manager/system/health': [{ report: null }] });
|
||
await assert.rejects(runHealthCheck(empty, { activity: fakeActivity() }), { message: HEALTH_FAILED_TEXT });
|
||
const broken = fakeApi({ 'POST /prompt_manager/system/health': [Object.assign(new Error('server'), { status: 500 })] });
|
||
await assert.rejects(runHealthCheck(broken, { activity: fakeActivity() }), { message: 'server' });
|
||
const invalid = fakeApi({ 'POST /prompt_manager/system/health': [{ job: null }] });
|
||
await assert.rejects(runHealthCheck(invalid, { activity: fakeActivity() }), { message: 'Invalid job' });
|
||
});
|
||
|
||
test('waiting stops when its signal aborts', async () => {
|
||
const { waitForJob } = await overviewModule;
|
||
const controller = new AbortController();
|
||
const activity = fakeActivity({ jobs: [job('running')] });
|
||
const waiting = waitForJob(activity, JOB, { signal: controller.signal });
|
||
await new Promise(resolve => setImmediate(resolve));
|
||
controller.abort(new Error('left'));
|
||
await assert.rejects(waiting, { message: 'left' });
|
||
assert.equal(activity.listeners(), 0);
|
||
const gone = new AbortController();
|
||
gone.abort(new Error('already'));
|
||
await assert.rejects(waitForJob(fakeActivity(), JOB, { signal: gone.signal }), { message: 'already' });
|
||
});
|