diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/index-dock.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/index-dock.js | 182 |
1 files changed, 182 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js new file mode 100644 index 0000000..b136578 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js @@ -0,0 +1,182 @@ +import { html, useState, useEffect, useRef } from './vendor/htm-preact.js'; +import { t, getLocale } from './i18n.js'; +import * as platform from './platform.js'; +import { formatSize } from './file-utils.js'; +import { navigate, session } from './hub-client.js'; +import { useStickyBand } from './sticky.js'; +import { Icon } from './icon.js'; +import { + fromLoopback, fromPush, mergeActivity, dockRows, hideRow, percent, queuedCount, +} from './index-dock-model.js'; + +// ── Indexing dock ─────────────────────────────────────────────────────────── +// +// A node asked to index a large directory works for hours, and the only sign of +// it used to be a progress bar in the one Settings panel that had started it — +// gone the moment the operator went anywhere else, and never shown at all when +// the directory was added from another machine. This band sits above the music +// bar on every page, one row per group with indexing under way; the roots of a +// group are walked one after another, so a group's row names the one being +// walked and the ones waiting (index-dock-model.js). + +// Loopback is cheap and local; a node with nothing to do is asked less often, +// and one that is not there at all, rarely. +const BUSY_POLL_MS = 2000; +const IDLE_POLL_MS = 5000; +const ABSENT_POLL_MS = 30000; + +// MNP pushes, reported by the group page holding the connection. Module state +// rather than a prop through App: the page and the dock are far apart, and a +// push every two seconds must not re-render the whole application. +const pushed = new Map(); +const listeners = new Set(); + +/** + * Called by group-page.js for an operator's connection only — an ordinary + * member keeps the sidebar dot. `status` is the push (or the handshake ack's + * `indexing`); `roots` the roots table the page opened, which names `root_pos`. + * `null` when the page lets go of the group: nothing will update the entry any + * more, and a stale "scanning" would stay on screen for ever. + */ +export function reportIndexPush(groupId, status, roots) { + if (status) pushed.set(groupId, fromPush(groupId, status, roots)); + else pushed.delete(groupId); + for (const fn of listeners) fn(); +} + +function useLoopbackActivity() { + const [local, setLocal] = useState({}); + useEffect(() => { + if (!platform.node.available) return undefined; + let stopped = false; + let timer = null; + let last = ''; + const poll = async () => { + let delay = IDLE_POLL_MS; + try { + const data = await platform.node.call('GET', '/api/index-status'); + const next = {}; + for (const g of (data && data.groups) || []) next[g.group_id] = fromLoopback(g); + if (Object.values(next).some((j) => j.scanning || j.queued.length)) { + delay = BUSY_POLL_MS; + } + const text = JSON.stringify(next); + if (!stopped && text !== last) { last = text; setLocal(next); } + } catch { + // No node on this machine, or one older than the route. + if (!stopped && last !== '{}') { last = '{}'; setLocal({}); } + delay = ABSENT_POLL_MS; + } + if (!stopped) timer = setTimeout(poll, delay); + }; + poll(); + return () => { stopped = true; clearTimeout(timer); }; + }, []); + return local; +} + +function usePushedActivity() { + const [snapshot, setSnapshot] = useState(() => Object.fromEntries(pushed)); + useEffect(() => { + const update = () => setSnapshot(Object.fromEntries(pushed)); + listeners.add(update); + update(); + return () => { listeners.delete(update); }; + }, []); + return snapshot; +} + +function DockRow({ row, onOpen, onHide }) { + const done = row.state === 'done'; + const waiting = !done && !row.scanning; + const listing = !done && row.scanning && !row.totalBytes; + const pct = percent(row); + const number = (v) => v.toLocaleString(getLocale()); + + const status = done ? t('indexdock.done') + : waiting ? t('indexdock.waiting') + : t(`indexdock.kind_${row.kind || 'scan'}`); + const where = row.root ? `${row.groupName} › ${row.root}` : row.groupName; + + let meta = ''; + if (listing) { + meta = t('indexdock.listing'); + } else if (!done && !waiting) { + meta = [ + t('indexdock.percent', { pct }), + `${formatSize(row.scannedBytes)} / ${formatSize(row.totalBytes)}`, + row.filesTotal + ? t('indexdock.files', { done: number(row.filesDone), total: number(row.filesTotal) }) + : '', + ].filter(Boolean).join(' · '); + } + + const count = queuedCount(row); + const next = done || !count ? '' + : Array.isArray(row.queued) ? t('indexdock.next', { roots: row.queued.join(', ') }) + : t('indexdock.next_count', { count: number(count) }); + + const moving = listing || waiting; + return html` + <div class="index-dock-row ${done ? 'index-dock-done' : ''}"> + <button type="button" class="index-dock-main" title=${t('indexdock.open')} + onClick=${onOpen}> + <span class="index-dock-head"> + <span class="index-dock-title"><strong>${status}</strong> ${where}</span> + ${meta && html`<span class="index-dock-meta">${meta}</span>`} + </span> + <span class="index-progress-bar index-dock-bar"> + <span class="index-progress-fill ${moving ? 'index-dock-indeterminate' : ''}" + style="width:${done ? 100 : moving ? 30 : pct}%"></span> + </span> + ${next && html`<span class="index-dock-next">${next}</span>`} + </button> + ${!done && html` + <button type="button" class="index-dock-hide" title=${t('indexdock.hide')} + aria-label=${t('indexdock.hide')} onClick=${onHide}> + <${Icon} name="close" /> + </button> + `} + </div> + `; +} + +export function IndexingDock({ groups }) { + const local = useLoopbackActivity(); + const remote = usePushedActivity(); + const memory = useRef({}); + const [, setTick] = useState(0); + // Publishes `--index-dock-h` for the sidebar, as the music bar publishes its + // own; withdrawn when the dock renders nothing. + const band = useStickyBand('--index-dock-h'); + + const jobs = mergeActivity(local, remote, groups); + const { rows, memory: nextMemory } = dockRows(jobs, memory.current, Date.now()); + memory.current = nextMemory; + + // Time alone changes what is shown — a burst crossing five seconds, a + // "finished" row expiring — so while anything is on or pending, look again. + const pending = Object.values(nextMemory).length > 0; + useEffect(() => { + if (!pending) return undefined; + const id = setInterval(() => setTick((n) => n + 1), 1000); + return () => clearInterval(id); + }, [pending]); + + if (!rows.length) return null; + return html` + <div class="index-dock" ref=${band}> + ${rows.map((row) => html` + <${DockRow} key=${row.groupId} row=${row} + onOpen=${() => { + session.openGroupTab = { groupId: row.groupId, tab: 'settings' }; + navigate('/group/' + row.groupId); + }} + onHide=${() => { + memory.current = hideRow(memory.current, row.groupId); + setTick((n) => n + 1); + }} /> + `)} + </div> + `; +} |