* 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.
133 lines
6.2 KiB
JavaScript
133 lines
6.2 KiB
JavaScript
/**
|
||
* System's row builders on the concept's markup (#249 System S2 Task 5), built with the DOM, never HTML strings:
|
||
* - `settingRow` is the concept's `setting(id, label, description, type, options)` (app.js 624–634): `.setting-row` >
|
||
* `div` > `label[for]` + `p`, then the control. A toggle is the Toolkit switch, as the concept's `hydrateSwitches`
|
||
* (708–717) makes it; a range is the concept's `min=1 max=16` unless the row gives its own bounds.
|
||
* - `operationRow` is `operation(title, desc, action, label, extra, danger)` (645–654): `.operation` > `div` > `h3` + `p`,
|
||
* then `.row-actions` with one button, `danger` when flagged.
|
||
* - `sectionNav` is `sectionNav(items, active, action)` (306–308): `.section-nav[role=group]` buttons with `aria-pressed`.
|
||
* Every listener is registered on the caller's `scope`, so disposing it leaves none behind.
|
||
*/
|
||
import { createButton, createSwitch } from '../shared/toolkit/core.js';
|
||
|
||
const INPUT_TYPES = new Set(['number', 'password', 'text', 'range']);
|
||
const RANGE_DEFAULTS = Object.freeze({ min: 1, max: 16 });
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
function requireText(values) {
|
||
for (const [name, value] of Object.entries(values)) {
|
||
if (typeof value !== 'string' || !value) throw new TypeError(`The row needs a ${name}`);
|
||
}
|
||
}
|
||
|
||
function requireScope(scope, callback, name) {
|
||
if (callback === undefined) return;
|
||
if (typeof callback !== 'function') throw new TypeError(`The row ${name} must be a function`);
|
||
if (typeof scope?.listen !== 'function') throw new TypeError('The row needs the section scope for its listener');
|
||
}
|
||
|
||
/** `[value, label]` pairs from strings or pairs. */
|
||
function optionPairs(options) {
|
||
if (!Array.isArray(options) || options.length === 0) throw new TypeError('A select row needs its options');
|
||
return options.map(option => (Array.isArray(option) ? option.map(String) : [String(option), String(option)]));
|
||
}
|
||
|
||
/** The control a spec describes, and how to read its value. */
|
||
function buildControl(id, spec) {
|
||
if (spec?.type === 'toggle') {
|
||
const field = createSwitch('', '', id);
|
||
const input = field.children[0];
|
||
// The concept's toggles are on unless configured off (`val !== false`).
|
||
input.checked = spec.checked !== false;
|
||
return { node: field, input, read: () => input.checked };
|
||
}
|
||
if (spec?.type === 'select') {
|
||
const select = el('select');
|
||
for (const [value, label] of optionPairs(spec.options)) {
|
||
const option = el('option', label);
|
||
option.value = value;
|
||
select.append(option);
|
||
}
|
||
if (spec.value !== undefined) select.value = String(spec.value);
|
||
return { node: select, input: select, read: () => select.value };
|
||
}
|
||
if (INPUT_TYPES.has(spec?.type)) {
|
||
const input = el('input');
|
||
input.type = spec.type;
|
||
const bounds = spec.type === 'range' ? { ...RANGE_DEFAULTS, ...spec } : spec;
|
||
for (const key of ['min', 'max', 'step']) if (bounds[key] !== undefined) input[key] = String(bounds[key]);
|
||
input.value = spec.value === undefined || spec.value === null ? '' : String(spec.value);
|
||
const numeric = spec.type === 'number' || spec.type === 'range';
|
||
return { node: input, input, read: () => (numeric ? Number(input.value) : input.value) };
|
||
}
|
||
throw new TypeError(`Unknown setting control: ${spec?.type}`);
|
||
}
|
||
|
||
/**
|
||
* One `.setting-row`. `control` is a spec (`{type:'toggle', checked}`, `{type:'select', options, value}` with string or
|
||
* `[value, label]` options, `{type:'number'|'password'|'text'|'range', value, min, max, step}`) or a ready-made node,
|
||
* which takes `id`. `onChange(value)` (on `scope`) reports the checked state, a number for number and range, else the
|
||
* text.
|
||
*/
|
||
export function settingRow({ id, label, description, control, scope, onChange }) {
|
||
requireText({ id, label, description });
|
||
requireScope(scope, onChange, 'onChange');
|
||
let built;
|
||
if (control && typeof control === 'object' && typeof control.type !== 'string' && 'tagName' in control) {
|
||
control.id = id;
|
||
built = { node: control, input: control, read: () => control.value };
|
||
} else {
|
||
built = buildControl(id, control);
|
||
built.input.id = id;
|
||
}
|
||
built.input.dataset.setting = id;
|
||
const row = el('div', undefined, 'setting-row');
|
||
const text = el('div');
|
||
const caption = el('label', label);
|
||
caption.htmlFor = id;
|
||
text.append(caption, el('p', description));
|
||
row.append(text, built.node);
|
||
if (onChange) scope.listen(built.input, 'change', () => onChange(built.read()));
|
||
return row;
|
||
}
|
||
|
||
/** One `.operation` row; `onClick(button)` runs on `scope`. */
|
||
export function operationRow({ title, description, label, danger = false, onClick, scope }) {
|
||
requireText({ title, description, label });
|
||
if (typeof onClick !== 'function') throw new TypeError('The operation needs onClick');
|
||
requireScope(scope, onClick, 'onClick');
|
||
const row = el('div', undefined, 'operation');
|
||
const text = el('div');
|
||
text.append(el('h3', title), el('p', description));
|
||
const actions = el('div', undefined, 'row-actions');
|
||
const action = createButton(label, danger ? 'danger' : '');
|
||
scope.listen(action, 'click', () => onClick(action));
|
||
actions.append(action);
|
||
row.append(text, actions);
|
||
return row;
|
||
}
|
||
|
||
/** The `.section-nav` group: one button per `[id, title]`, the `active` one pressed; `onSelect(id)` runs on `scope`. */
|
||
export function sectionNav({ label, sections, active, onSelect, scope }) {
|
||
requireText({ label });
|
||
if (typeof onSelect !== 'function') throw new TypeError('The section nav needs onSelect');
|
||
requireScope(scope, onSelect, 'onSelect');
|
||
const nav = el('div', undefined, 'section-nav');
|
||
nav.setAttribute('role', 'group');
|
||
nav.setAttribute('aria-label', label);
|
||
for (const [id, title] of sections) {
|
||
const item = createButton(title);
|
||
item.dataset.value = id;
|
||
item.setAttribute('aria-pressed', String(id === active));
|
||
scope.listen(item, 'click', () => onSelect(id));
|
||
nav.append(item);
|
||
}
|
||
return nav;
|
||
}
|