aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-14 11:17:42 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-14 11:17:42 +0200
commit2fb66c5baecdd8b49be904f6244f66ba04f68061 (patch)
tree3b80940e1819a811f6ec2c04831e3616a74a8eae /packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js
parenta294c1d338ba4c20d66873d593d1c101e69c5a40 (diff)
downloadmeshbay-2fb66c5baecdd8b49be904f6244f66ba04f68061.tar.gz
feat(ui): an indexing dock above the music bar, on every page
Adding a large directory left the operator nothing to look at once they left the Settings panel that started it, and nothing at all when it was added from another machine. A band now sits above the music bar on every page: one row per group with indexing under way, naming the root being walked, percent, bytes and files, and the roots waiting their turn; "indexing finished" for a few seconds at the end. A click opens the group's Settings, and × hides the row until that group is idle. Two sources feed it. On the node's own machine the desktop client polls the loopback `GET /api/index-status` for every group, whatever the route. An operator's group page forwards MNP `index_progress` pushes, resolving the root from the roots table it opened; an ordinary member keeps the sidebar dot only, and a page clears its row when it lets go of the group. Where both describe a group, loopback wins. Reconcile passes and watchdog bursts show only past 1 GB or 5 s, so a single dropped file does not flash a bar. The logic lives in index-dock-model.js, which has no imports and is tested under node. The dock publishes `--index-dock-h` and the sidebar stops above it and the music bar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js126
1 files changed, 126 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js b/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js
new file mode 100644
index 0000000..bd4586b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js
@@ -0,0 +1,126 @@
+/**
+ * What the indexing dock shows, worked out from what nodes report. No DOM and no
+ * imports, so tests/test_indexing_dock.py runs this file under node as it is.
+ *
+ * Two sources describe a group's indexing:
+ *
+ * - The loopback API (`GET /api/index-status`, the node's ui/app.py) answers on
+ * the node's own machine, for every group it hosts, whatever page is open,
+ * and names the roots.
+ * - MNP `index_progress` pushes reach an operator anywhere, but only while that
+ * group's page holds a connection, and carry counters only (decision D3): the
+ * root under way is a position in the roots table the page already opened,
+ * and the roots waiting are a count.
+ *
+ * Where both describe a group, the loopback answer wins. It is the fuller one.
+ */
+
+// A reconcile pass or a burst of watchdog events is usually a file or two, done
+// in less time than it takes to read a bar. Shown only past either threshold;
+// a root walked for the first time, or again, is always shown.
+export const NOISE_BYTES = 1024 ** 3;
+export const NOISE_MS = 5000;
+// How long "indexing finished" stays on screen.
+export const DONE_MS = 4000;
+
+export function fromLoopback(g) {
+ return {
+ groupId: g.group_id,
+ groupName: g.group_name || '',
+ scanning: Boolean(g.scanning),
+ kind: g.kind || '',
+ root: g.root || '',
+ scannedBytes: g.scanned_bytes || 0,
+ totalBytes: g.total_bytes || 0,
+ filesDone: g.files_done || 0,
+ filesTotal: g.files_total || 0,
+ queued: Array.isArray(g.queued) ? g.queued : [],
+ };
+}
+
+export function fromPush(groupId, s, roots) {
+ const pos = Number.isInteger(s.root_pos) ? s.root_pos : -1;
+ const known = pos >= 0 && Array.isArray(roots) && roots[pos];
+ return {
+ groupId,
+ groupName: '',
+ scanning: Boolean(s.scanning),
+ kind: s.kind || '',
+ root: known ? (roots[pos].name || '') : '',
+ scannedBytes: s.scanned_bytes || 0,
+ totalBytes: s.total_bytes || 0,
+ filesDone: s.files_done || 0,
+ filesTotal: s.files_total || 0,
+ queued: Number.isInteger(s.queued) ? s.queued : 0,
+ };
+}
+
+export function queuedCount(job) {
+ return Array.isArray(job.queued) ? job.queued.length : (job.queued || 0);
+}
+
+export function percent(job) {
+ return job.totalBytes
+ ? Math.min(100, Math.round(100 * job.scannedBytes / job.totalBytes)) : 0;
+}
+
+/** One job per group, loopback first, named after the hub's group list. */
+export function mergeActivity(local, pushed, groups) {
+ const names = new Map((groups || []).map((g) => [g.id, g.name]));
+ const ids = [...new Set([...Object.keys(local), ...Object.keys(pushed)])];
+ return ids.map((gid) => {
+ const job = local[gid] || pushed[gid];
+ return { ...job, groupName: names.get(gid) || job.groupName || gid.slice(0, 8) };
+ });
+}
+
+function busy(job) {
+ return job.scanning || queuedCount(job) > 0;
+}
+
+function loud(job, since, now) {
+ // '' while scanning is a node older than `kind`: shown, as it always was.
+ return job.kind === 'scan' || job.kind === 'rescan' || job.kind === ''
+ || queuedCount(job) > 0
+ || job.totalBytes >= NOISE_BYTES
+ || now - since >= NOISE_MS;
+}
+
+/**
+ * The rows to draw, and what to remember for the next call.
+ *
+ * `memory` is per group: when the current walk was first seen, whether its row
+ * has been shown (a burst that crossed the threshold stays up until it ends),
+ * whether the operator hid it, and when it finished. Pure: same arguments, same
+ * answer, so a render that runs twice does not flash anything twice.
+ */
+export function dockRows(jobs, memory, now) {
+ const next = {};
+ const rows = [];
+ for (const job of jobs) {
+ const gid = job.groupId;
+ const prev = memory[gid] || {};
+ if (busy(job)) {
+ const sig = `${job.kind}|${job.root}`;
+ const since = prev.busy && prev.sig === sig ? prev.since : now;
+ const shown = Boolean(prev.busy && prev.shown) || loud(job, since, now);
+ const hidden = Boolean(prev.busy && prev.hidden);
+ next[gid] = { busy: true, sig, since, shown, hidden, doneAt: 0 };
+ if (shown && !hidden) rows.push({ ...job, state: 'running' });
+ } else if (prev.busy && prev.shown && !prev.hidden) {
+ next[gid] = { busy: false, doneAt: now };
+ rows.push({ ...job, state: 'done' });
+ } else if (prev.doneAt && now - prev.doneAt < DONE_MS) {
+ next[gid] = prev;
+ rows.push({ ...job, state: 'done' });
+ }
+ }
+ return { rows, memory: next };
+}
+
+/** Hidden until that group has nothing left to index. */
+export function hideRow(memory, groupId) {
+ const prev = memory[groupId];
+ if (!prev || !prev.busy) return memory;
+ return { ...memory, [groupId]: { ...prev, hidden: true } };
+}