diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-14 11:17:42 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-14 11:17:42 +0200 |
| commit | 2fb66c5baecdd8b49be904f6244f66ba04f68061 (patch) | |
| tree | 3b80940e1819a811f6ec2c04831e3616a74a8eae /packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js | |
| parent | a294c1d338ba4c20d66873d593d1c101e69c5a40 (diff) | |
| download | meshbay-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.js | 126 |
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 } }; +} |