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:
@@ -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,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 }),
|
||||
];
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
+17
-39
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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