aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
diff options
context:
space:
mode:
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.js182
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>
+ `;
+}