feat(system): show Diagnostics & logs natively and retire the hand-off panel

System › Diagnostics & logs is native (D109, D110): the concept's
Diagnostics surface with the live database, source, Viewer.js and renderer
lines, Run checks (the shared Health report), the diagnostic report, Inspect
and the confirmed test link; and the Logs surface with the minimum-level
viewer, Lines 1-500, statistics, the log download, the runtime level and
console setting saved by Save preferences, the log files with their
downloads, Review log cleanup (the rotated_logs maintenance plan, no backup
box, Listed files) and a confirmed Clear all logs.

D94's hand-off panel, SECTION_HANDOFFS and the undelivered runtime adapter
are gone. The browser server logs into its own temporary root.

Part of #329
This commit is contained in:
Vito Sansevero
2026-10-27 06:42:37 -07:00
parent 547bc578b9
commit be4ea95ec4
17 changed files with 1792 additions and 79 deletions
+22 -4
View File
@@ -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 }) });
}
+2 -2
View File
@@ -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' },
];
+581
View File
@@ -0,0 +1,581 @@
/**
* 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.',
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?.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;
confirmDialog({ scope, trigger: button, title: 'Create test link', sentence: testLinkSentence(target), label: 'Create',
onConfirm: 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();
drafts.load('runtime').catch(() => {});
return [level, consoleRow];
}
/** 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);
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;
const clear = operationRow({ scope, title: 'Clear all logs', description: COPY.clearDescription, label: 'Clear all logs…',
danger: true, onClick: button => 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);
surface.append(el('h2', 'Logs'), toolbar, lines, stats, ...runtimeRows({ scope, drafts }), 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();
drafts.load('runtime').catch(() => {});
}
scope.listen(level, 'change', readLogs);
scope.listen(limit, 'change', readLogs);
scope.listen(refresh, 'click', () => { readLogs(); readStats(); });
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 }),
];
}
+2 -1
View File
@@ -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 });
}
+10 -2
View File
@@ -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 }),
+17 -39
View File
@@ -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.
@@ -70,18 +61,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 +79,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 +107,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 +118,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 +130,14 @@ 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`); an id without a mount shows Overview's. `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
@@ -214,8 +192,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);
const mount = sections[next] ?? sections.overview;
const nodes = mount(layout, { api, activity, scope: sectionScope, drafts: store });
layout.prepend(...nodes);
paintStatus();
}
+5 -3
View File
@@ -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 */ }