A new Activity page on the rail (and the home page's recent list) shows, by day,
what the plugin changed. Record only; undo comes later (AD-018).
- api/activity_log.py keeps the newest 500 entries in the ComfyUI user folder.
A middleware records this plugin's and Anomalous_TTS's write requests after
they succeed (including the 200 + {"status": "error"} style as failure), from
one route table; the log never fails the request it records.
- activity_canvas.js: a press inside Anomalous snapshots the canvas; the next
press or key outside it (or the next press inside) logs the difference, so
every button, drag and dialog that writes nodes is covered and the user's own
edits are not. Opening another workflow is logged as that. <html> carries an
Anomalous class and does not count as the plugin's interface.
- activity_diff.js: pure snapshot and difference; activity_log.js: client and
wording; ui_activity.js: the page (filters, details, find on canvas, clear)
and the home list.
- Tests: tests/activity_diff.mjs, tests/test_activity_log.py (local).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
7.1 KiB
JavaScript
177 lines
7.1 KiB
JavaScript
/**
|
|
* The activity page (everything Anomalous changed, by day, with filters, details and
|
|
* "find on canvas") and the home page's "recent" list. Reads through activity_log.js;
|
|
* each render cancels the previous one's request.
|
|
*/
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { translate as t } from './locales.js';
|
|
import { changeLine, clearActivity, dayLabel, entrySummary, fetchActivity, timeLabel } from './activity_log.js';
|
|
import { changeNodeId } from './activity_diff.js';
|
|
import { anomalousConfirm } from './ui_dialog.js';
|
|
|
|
const FILTERS = [['', 'activityAll'], ['canvas', 'activityCanvas'], ['file', 'activityFiles']];
|
|
const PAGE_SIZE = 300;
|
|
const RECENT_COUNT = 5;
|
|
let pageController = null;
|
|
let recentController = null;
|
|
let filter = '';
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function button(className, label, onClick) {
|
|
const node = el('button', className, label);
|
|
node.type = 'button';
|
|
node.onclick = onClick;
|
|
return node;
|
|
}
|
|
|
|
/** Selects and centers the node a change names, if it is on the canvas now. */
|
|
function locateNode(change, note) {
|
|
const id = changeNodeId(change);
|
|
const graph = app.canvas?.graph || app.graph;
|
|
const node = id === null ? null : graph?.getNodeById?.(Number(id)) ?? graph?.getNodeById?.(id);
|
|
if (!node) {
|
|
note.textContent = t('activityLocateMissing');
|
|
return;
|
|
}
|
|
app.canvas.selectNode?.(node);
|
|
app.canvas.centerOnNode?.(node);
|
|
app.canvas.setDirty?.(true, true);
|
|
}
|
|
|
|
function renderEntry(entry, { compact = false } = {}) {
|
|
const row = el('div', `anomalous-activity-entry is-${entry.source === 'canvas' ? 'canvas' : 'file'}`);
|
|
const head = el('div', 'anomalous-activity-head');
|
|
head.append(el('span', 'anomalous-activity-time', timeLabel(entry.time)),
|
|
el('span', 'anomalous-activity-source', t(entry.source === 'canvas' ? 'activityCanvas' : 'activityFiles')),
|
|
el('span', 'anomalous-activity-summary', entrySummary(entry)));
|
|
row.appendChild(head);
|
|
const changes = (entry.detail?.changes || []).filter(change => change.kind !== 'opened');
|
|
if (compact || !changes.length) return row;
|
|
|
|
// Canvas entries open to one line per change.
|
|
head.classList.add('is-expandable');
|
|
head.tabIndex = 0;
|
|
head.setAttribute('role', 'button');
|
|
head.setAttribute('aria-expanded', 'false');
|
|
const details = el('div', 'anomalous-activity-details');
|
|
details.hidden = true;
|
|
const note = el('div', 'anomalous-activity-note');
|
|
for (const change of changes) {
|
|
const line = el('div', 'anomalous-activity-change');
|
|
line.appendChild(el('span', 'anomalous-activity-change-text', changeLine(change)));
|
|
line.title = changeLine(change);
|
|
if (change.kind !== 'removed') {
|
|
line.appendChild(button('anomalous-activity-locate', t('activityLocate'), () => locateNode(change, note)));
|
|
}
|
|
details.appendChild(line);
|
|
}
|
|
const shown = changes.length;
|
|
const total = entry.detail?.total || shown;
|
|
if (total > shown) details.appendChild(el('div', 'anomalous-activity-more', t('activityMore', { count: total - shown })));
|
|
details.appendChild(note);
|
|
const toggle = () => {
|
|
details.hidden = !details.hidden;
|
|
head.setAttribute('aria-expanded', String(!details.hidden));
|
|
};
|
|
head.onclick = toggle;
|
|
head.onkeydown = (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
toggle();
|
|
}
|
|
};
|
|
row.appendChild(details);
|
|
return row;
|
|
}
|
|
|
|
function renderList(list, entries) {
|
|
list.replaceChildren();
|
|
let day = null;
|
|
for (const entry of entries) {
|
|
const label = dayLabel(entry.time);
|
|
if (label !== day) {
|
|
day = label;
|
|
list.appendChild(el('div', 'anomalous-activity-day', label));
|
|
}
|
|
list.appendChild(renderEntry(entry));
|
|
}
|
|
}
|
|
|
|
/** The whole page into `panel`. */
|
|
export function renderActivityPage(owner, panel) {
|
|
owner.flushCanvasActivity?.();
|
|
pageController?.abort();
|
|
pageController = new AbortController();
|
|
const { signal } = pageController;
|
|
|
|
const page = el('div', 'anomalous-activity');
|
|
const bar = el('div', 'anomalous-activity-bar');
|
|
const chips = el('div', 'anomalous-activity-filters');
|
|
const list = el('div', 'anomalous-activity-list');
|
|
const load = () => {
|
|
list.replaceChildren(el('div', 'anomalous-activity-empty', t('loading')));
|
|
fetchActivity({ limit: PAGE_SIZE, source: filter, signal })
|
|
.then(entries => {
|
|
if (signal.aborted) return;
|
|
if (entries.length) renderList(list, entries);
|
|
else list.replaceChildren(el('div', 'anomalous-activity-empty', t('activityEmpty')));
|
|
})
|
|
.catch((error) => {
|
|
if (signal.aborted) return;
|
|
list.replaceChildren(el('div', 'anomalous-activity-empty', `${t('activityLoadFailed')} ${error.message}`));
|
|
});
|
|
};
|
|
for (const [value, key] of FILTERS) {
|
|
const chip = button(`anomalous-activity-chip${value === filter ? ' active' : ''}`, t(key), () => {
|
|
filter = value;
|
|
chips.querySelectorAll('.anomalous-activity-chip').forEach(node => node.classList.toggle('active', node === chip));
|
|
load();
|
|
});
|
|
chips.appendChild(chip);
|
|
}
|
|
const clear = button('anomalous-activity-clear', t('activityClear'), async () => {
|
|
if (!await anomalousConfirm(t('activityClearConfirm'), t('activityTitle'))) return;
|
|
try {
|
|
await clearActivity();
|
|
load();
|
|
} catch (error) {
|
|
list.prepend(el('div', 'anomalous-activity-empty', `${t('activityLoadFailed')} ${error.message}`));
|
|
}
|
|
});
|
|
bar.append(chips, clear);
|
|
page.append(el('p', 'anomalous-activity-lead', t('activityLead')), bar, list);
|
|
panel.replaceChildren(page);
|
|
load();
|
|
}
|
|
|
|
/** The home page's short list: the latest few entries and a link to the page. */
|
|
export function renderRecentActivity(owner, container) {
|
|
recentController?.abort();
|
|
recentController = new AbortController();
|
|
const { signal } = recentController;
|
|
const section = el('section', 'anomalous-home-recent');
|
|
const head = el('div', 'anomalous-home-recent-head');
|
|
head.append(el('span', 'anomalous-home-recent-title', t('activityRecent')),
|
|
button('anomalous-home-link', t('activityViewAll'), () => owner.goTo('activity')));
|
|
const list = el('div', 'anomalous-activity-list is-compact');
|
|
section.append(head, list);
|
|
container.appendChild(section);
|
|
owner.flushCanvasActivity?.();
|
|
fetchActivity({ limit: RECENT_COUNT, signal })
|
|
.then((entries) => {
|
|
if (signal.aborted) return;
|
|
if (!entries.length) list.appendChild(el('div', 'anomalous-activity-empty', t('activityEmpty')));
|
|
for (const entry of entries) list.appendChild(renderEntry(entry, { compact: true }));
|
|
})
|
|
.catch(() => {
|
|
if (!signal.aborted) section.remove(); // the home page works without it
|
|
});
|
|
}
|