Merge remote-tracking branch 'origin/feat/329-s12-pr2' into feat/329-s12-pr3

This commit is contained in:
Vito Sansevero
2026-10-27 07:43:42 -07:00
5 changed files with 121 additions and 47 deletions
@@ -294,7 +294,8 @@ test('Lines accepts 1 to 500 and sends nothing when invalid', async ({ page, req
const limit = page.locator('#log-limit');
await expect(page.locator('label[for="log-limit"]')).toHaveText('Lines');
await expect(limit).toHaveValue('100');
expect(await limit.evaluate(node => [node.type, node.min, node.max])).toEqual(['number', '1', '500']);
expect(await limit.evaluate(node => [node.type, node.min, node.max, node.title]))
.toEqual(['number', '1', '500', 'Lines to show: a whole number from 1 to 500.']);
await limit.fill('20');
await limit.dispatchEvent('change');
await expect.poll(() => urls.at(-1)).toBe('?limit=20');
@@ -424,6 +425,17 @@ test('a refused runtime value is marked invalid', async ({ page }) => {
await expect(select).not.toHaveAttribute('aria-invalid');
});
test('runtime values that cannot be read stay disabled and say so', async ({ page }) => {
await page.route(`**${CONFIG_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
body: JSON.stringify({ success: false, error: 'boom' }) }));
await openDiagnostics(page);
for (const label of ['Runtime log level', 'Console logging']) {
const row = settingOf(page, label);
await expect(row.locator('p.small')).toHaveText('The current value could not be read.');
await expect(row.locator('select, input[type="checkbox"]')).toBeDisabled();
}
});
// ── Task 6: log files, the reviewed cleanup and Clear all logs ─────────────────────────────────────────────────────
const LOG_FOLDER = { rotated: [3000, 2048, 1024], other: true, link: true, active: 100 };
@@ -529,6 +541,26 @@ test('Clear all logs states both counts, confirms first and reads everything aga
expect(await names(request)).toEqual(['prompt_manager.log', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak']);
});
test('Clear all logs reads the files again before it states what it deletes', async ({ page, request }) => {
await seedLogs(request, { rotated: [1024], active: 100 });
await openDiagnostics(page);
const details = await openFiles(page);
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 1$/);
// The log rolls over while the page stays open: Clear names what it deletes now, not the count shown.
await seedLogs(request, { records: false, rotated: [1024, 1024, 1024] });
await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click();
await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes 3 rotated copies \(3\.0 KB\) /);
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 3$/);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
// A list that cannot be read gives the general sentence, never an old count.
await page.route('**/prompt_manager/logs/files', route => route.fulfill({ status: 500, contentType: 'application/json',
body: JSON.stringify({ success: false, error: 'boom', files: [] }) }));
await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click();
await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes its rotated copies and clears/);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
expect(await names(request)).toEqual(['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3']);
});
test('each listed file downloads by its returned name and a link has no Download', async ({ page, request }) => {
await seedLogs(request, LOG_FOLDER);
await openDiagnostics(page);
+23 -4
View File
@@ -74,10 +74,22 @@ def _manager():
def repoint_logs(root: Path) -> Path:
"""Point the logger at ``<root>/logs`` before anything is logged."""
manager = _manager()
"""Point the logger at ``<root>/logs`` before anything is logged.
The logger is built while ``logs_dir`` answers that folder, so its first
handler never opens a log file anywhere else (a node folder with a legacy
database would otherwise name a real log folder).
"""
from utils import library_location
folder = Path(root) / "logs"
folder.mkdir(exist_ok=True)
original = library_location.logs_dir
library_location.logs_dir = lambda **_kwargs: str(folder)
try:
manager = _manager()
finally:
library_location.logs_dir = original
with manager._files_lock:
manager.log_dir = folder
manager._setup_loggers()
@@ -102,6 +114,9 @@ def _record(time_text, level, message, index):
def _touch(path: Path, size: int, moment: datetime.datetime) -> None:
# A link at this name is replaced, never written through.
if path.is_symlink():
path.unlink()
with open(path, "wb") as handle:
handle.write(b"x" * size)
stamp = moment.timestamp()
@@ -130,7 +145,9 @@ def register_diagnostics_commands(app, routes, api, root: Path):
with manager._files_lock:
held = manager._acquire_file_handlers()
try:
for entry in os.scandir(log_dir):
with os.scandir(log_dir) as entries:
found = list(entries)
for entry in found:
path = Path(entry.path)
if entry.name == ACTIVE and entry.is_file(follow_symlinks=False):
manager._truncate_main_log(path)
@@ -210,7 +227,9 @@ def register_diagnostics_commands(app, routes, api, root: Path):
manager = _manager()
log_dir = folder()
entries = []
for entry in sorted(os.scandir(log_dir), key=lambda item: item.name):
with os.scandir(log_dir) as listing:
found = sorted(listing, key=lambda item: item.name)
for entry in found:
info = entry.stat(follow_symlinks=False)
entries.append(
{"name": entry.name, "size": info.st_size, "link": entry.is_symlink()}
+29 -23
View File
@@ -1,9 +1,9 @@
{
"admin.js": {
"lines": 32,
"lines": 37,
"branches": 61,
"functions": 27,
"statements": 32
"functions": 32,
"statements": 37
},
"api-client.js": {
"lines": 98,
@@ -48,10 +48,10 @@
"statements": 98
},
"gallery.js": {
"lines": 45,
"branches": 66,
"functions": 48,
"statements": 45
"lines": 49,
"branches": 68,
"functions": 50,
"statements": 49
},
"html-escape.js": {
"lines": 98,
@@ -133,7 +133,7 @@
},
"sse-stream.js": {
"lines": 98,
"branches": 90,
"branches": 92,
"functions": 98,
"statements": 98
},
@@ -181,7 +181,7 @@
},
"workspace/activity-scan.mjs": {
"lines": 96,
"branches": 87,
"branches": 90,
"functions": 98,
"statements": 96
},
@@ -420,10 +420,10 @@
"statements": 98
},
"workspace/maintenance-review.mjs": {
"lines": 94,
"branches": 86,
"functions": 90,
"statements": 94
"lines": 95,
"branches": 87,
"functions": 91,
"statements": 95
},
"workspace/media-viewer.mjs": {
"lines": 98,
@@ -515,15 +515,21 @@
"functions": 89,
"statements": 98
},
"workspace/system-diagnostics.mjs": {
"lines": 95,
"branches": 86,
"functions": 94,
"statements": 95
},
"workspace/system-integrations.mjs": {
"lines": 96,
"branches": 88,
"functions": 91,
"statements": 96
"lines": 97,
"branches": 90,
"functions": 92,
"statements": 97
},
"workspace/system-maintenance.mjs": {
"lines": 93,
"branches": 80,
"branches": 81,
"functions": 92,
"statements": 93
},
@@ -546,10 +552,10 @@
"statements": 98
},
"workspace/system-tagging.mjs": {
"lines": 80,
"branches": 70,
"functions": 75,
"statements": 80
"lines": 98,
"branches": 94,
"functions": 95,
"statements": 98
},
"workspace/system-thumbnails.mjs": {
"lines": 98,
@@ -560,7 +566,7 @@
"workspace/system.mjs": {
"lines": 98,
"branches": 92,
"functions": 86,
"functions": 98,
"statements": 98
},
"workspace/theme.mjs": {
+28 -9
View File
@@ -66,6 +66,8 @@ export const COPY = Object.freeze({
statsUnread: 'The log statistics could not be read.',
filesUnread: 'The log files could not be read.',
configUnread: 'The retention settings could not be read.',
runtimeUnread: 'The current value could not be read.',
limitHint: 'Lines to show: a whole number from 1 to 500.',
logDownloadFailed: 'The log could not be downloaded.',
reportDone: 'Diagnostic report downloaded.',
reportFailed: 'The diagnostic report could not be downloaded. Nothing was saved to this device.',
@@ -297,15 +299,16 @@ function testLinkRow({ api, scope, notify }) {
onClick: button => {
if (button.getAttribute('aria-disabled') === 'true' || !prompt) return;
const target = prompt;
// The button is held until the write answered, so a second press cannot write a second record.
confirmDialog({ scope, trigger: button, title: 'Create test link', sentence: testLinkSentence(target), label: 'Create',
onConfirm: async () => {
onConfirm: () => pressing(button, async () => {
try {
const answer = await api.post(TEST_LINK_URL, { prompt_id: target.id });
if (!scope.signal.aborted) notify(`Test link created (image record ${answer?.result?.image_id}).`);
} catch {
if (!scope.signal.aborted) notify(COPY.testLinkFailed);
}
} });
}) });
} });
const button = row.querySelector('.row-actions > button');
const description = row.querySelector('p');
@@ -410,8 +413,16 @@ function runtimeRows({ scope, drafts }) {
};
scope.cleanup(drafts.subscribe(repaint));
repaint();
drafts.load('runtime').catch(() => {});
return [level, consoleRow];
// A failed read leaves both controls disabled; each says why (the value the server holds is not known).
const notes = [level, consoleRow].map(row => {
const note = el('p', COPY.runtimeUnread, 'small');
return () => row.children[0].append(note);
});
const reload = () => drafts.load('runtime').then(() => {}, () => {
if (!scope.signal.aborted) for (const show of notes) show();
});
reload();
return { rows: [level, consoleRow], reload };
}
/** The "Logs" surface. */
@@ -436,6 +447,7 @@ function logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnl
limit.max = String(MAX_LIMIT);
limit.step = '1';
limit.value = String(DEFAULT_LIMIT);
limit.title = COPY.limitHint;
const refresh = createButton('Refresh');
const downloadLog = createButton('Download log');
toolbar.append(levelLabel, level, limitLabel, limit, refresh, downloadLog);
@@ -456,11 +468,18 @@ function logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnl
const retention = el('p', LOADING, 'small');
const list = el('div', undefined, 'operation-list');
let listed = null;
// The confirmation names what Clear deletes now: the files are read again before it opens (a read that fails
// gives the general sentence, never an old count).
const clear = operationRow({ scope, title: 'Clear all logs', description: COPY.clearDescription, label: 'Clear all logs…',
danger: true, onClick: button => confirmDialog({ scope, trigger: button, title: 'Clear all logs', sentence: clearSentence(listed),
label: 'Clear', danger: true, onConfirm: clearLogs }) });
danger: true, onClick: button => pressing(button, async () => {
await readFiles();
if (scope.signal.aborted) return;
confirmDialog({ scope, trigger: button, title: 'Clear all logs', sentence: clearSentence(listed), label: 'Clear',
danger: true, onConfirm: clearLogs });
}) });
files.append(el('summary', 'Log files & retention'), line, review, retention, list, clear);
surface.append(el('h2', 'Logs'), toolbar, lines, stats, ...runtimeRows({ scope, drafts }), files);
const runtime = runtimeRows({ scope, drafts });
surface.append(el('h2', 'Logs'), toolbar, lines, stats, ...runtime.rows, files);
const logsRead = latestOnly({ lifetime: scope.signal });
async function readLogs() {
@@ -553,12 +572,12 @@ function logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnl
if (scope.signal.aborted) return;
if (answer) notify(clearToast(answer.results));
readAll();
drafts.load('runtime').catch(() => {});
runtime.reload();
}
scope.listen(level, 'change', readLogs);
scope.listen(limit, 'change', readLogs);
scope.listen(refresh, 'click', () => { readLogs(); readStats(); });
scope.listen(refresh, 'click', () => { readLogs(); readStats(); readFiles(); });
scope.listen(downloadLog, 'click', () => saveLog(downloadLog, ACTIVE_LOG));
scope.listen(review, 'click', () => reviewPlan({ api, store: activity, scope, operation: 'rotated_logs', trigger: review,
notify, navigate }));
+8 -10
View File
@@ -40,10 +40,9 @@ async function runningJob(error) {
return jobs.running()[0];
}
function el(tag, text, className) {
function el(tag, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
node.className = className;
return node;
}
@@ -136,8 +135,7 @@ export const DELIVERED_SECTIONS = Object.freeze({
/**
* 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`); an id without a mount shows Overview's. `activity` is the
* page's Activity store, which follows the jobs a section starts.
* (`(container, {api, activity, scope, drafts}) => nodes`) for every section. `activity` is the page's Activity store, which follows the jobs a section starts.
*
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
* until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on
@@ -162,9 +160,9 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE
scope.cleanup(subscribePreferences(window, next => reconcile(next.values)));
const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope,
onSelect: id => navigate(`#/system/${id}`) });
const layout = el('div', undefined, 'settings-layout');
const saveRow = el('div', undefined, 'save-row');
const status = el('span', undefined, 'small');
const layout = el('div', 'settings-layout');
const saveRow = el('div', 'save-row');
const status = el('span', 'small');
status.id = 'settings-status';
const save = createButton('Save preferences', 'ca-primary');
saveRow.append(status, save);
@@ -192,8 +190,8 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE
sectionScope = createScope();
for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next));
while (layout.firstChild !== saveRow) layout.firstChild.remove();
const mount = sections[next] ?? sections.overview;
const nodes = mount(layout, { api, activity, scope: sectionScope, drafts: store });
// Every section is delivered (S12), and the router only names known sections (state.mjs).
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store });
layout.prepend(...nodes);
paintStatus();
}