aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-12 00:17:32 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-12 00:17:32 +0200
commit14911aae21ec4975f7a805b91e3c4867cb41974b (patch)
tree8c00230a88053905fd77938f3a9539efeb4c3094 /packages/meshbay-hub
parentf6f107e02c7a4aa7326c9a82310a239327d81ed6 (diff)
downloadmeshbay-14911aae21ec4975f7a805b91e3c4867cb41974b.tar.gz
feat: let the operator pause and resume a root's indexing (MNP 6.2)
A whole-root walk now checks its root before every file: removed or ejected, it stops instead of reading the drive to the end; paused, it finishes from the hash cache alone. A paused root is not watched, not swept by reconcile and survives a restart (roster), its indexed files stay served. Resume runs the reconcile sweep over the root, so entries and their enrichment are kept. Signed root_pause / root_resume over MNP, loopback routes, CLI `root pause|resume`, a pause button on the indexing dock and pause/resume in the roots table of Group settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/index-dock.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js10
-rw-r--r--packages/meshbay-hub/tests/test_indexing_dock.py34
18 files changed, 208 insertions, 25 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 48b1c80..3e3b483 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -510,6 +510,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setNodeRoots(msg.roots);
}
};
+ // The dock's pause button, for a node that is not on this machine.
+ const pauseFromDock = (rootName) =>
+ transport.pauseRoot(groupId, rootName, transport.signFn);
// The node's own scan (a root added while we were already connected,
// or reconcile catching one back up) — never the entries, just
// enough to animate the sidebar dot. Guaranteed a final push at the
@@ -526,7 +529,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// The operator's indexing dock. The push names no root, so the
// page that opened the roots table names it.
if (transport.memberRole === 'operator') {
- reportIndexPush(groupId, status, nodeRootsRef.current);
+ reportIndexPush(groupId, status, nodeRootsRef.current, pauseFromDock);
}
};
setOperatorPaired(transport.memberRole === 'operator');
@@ -637,7 +640,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
}
}
if (transport.memberRole === 'operator' && ack.indexing) {
- reportIndexPush(groupId, ack.indexing, indexMsg.roots || nodeRootsRef.current);
+ reportIndexPush(groupId, ack.indexing, indexMsg.roots || nodeRootsRef.current,
+ pauseFromDock);
}
} catch (err) {
if (cancelled) return;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 1a367be..a666c29 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -55,8 +55,9 @@ async function readLocalNode(node, groupId, attempts = NODE_CHECK_ATTEMPTS,
* mode="live" — a hosted group. What widens the node's sharing — adding
* a directory, its `writable` and `removable` switches — goes
* through the loopback API only, so only on the node's own
- * machine. Removing, ejecting and plugging are signed ops
- * over MNP, or the loopback API when there is no connection.
+ * machine. Removing, ejecting, plugging, pausing and
+ * resuming are signed ops over MNP, or the loopback API when
+ * there is no connection.
* mode="local" — the wizard, before the group exists. Changes are held in
* an array the caller owns; nothing is persisted until the
* group is attached.
@@ -72,7 +73,7 @@ async function readLocalNode(node, groupId, attempts = NODE_CHECK_ATTEMPTS,
*
* Props:
* roots — the node's current roots: { name, path, writable,
- * removable, ejected, available, kind }
+ * removable, ejected, paused, available, kind }
* groupId — the group id
* transport — MeshBayTransport instance, or null when not connected
* signFn — signing function for admin ops
@@ -153,7 +154,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
// signature proves that the operator's key signed, not that the operator
// meant it: in a browser that key is driven by code the hub serves.
//
- // Narrowing — remove, eject, plug — MNP first, then loopback.
+ // Narrowing — remove, eject, plug, pause, resume — MNP first, then loopback.
const overMnp = !isLocal && transport && transport.connected;
const overLoopback = !isLocal && !overMnp && nodeAvail;
const onNodeMachine = !isLocal && nodeAvail;
@@ -220,6 +221,18 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
else throw new Error(t('node.root_no_route'));
}), [overMnp, overLoopback, transport, groupId, signFn, run]);
+ const doPauseRoot = useCallback((rootName) => run(async () => {
+ if (overMnp) await transport.pauseRoot(groupId, rootName, signFn);
+ else if (overLoopback) await platform.node.op('pauseRoot', { groupId, rootName });
+ else throw new Error(t('node.root_no_route'));
+ }), [overMnp, overLoopback, transport, groupId, signFn, run]);
+
+ const doResumeRoot = useCallback((rootName) => run(async () => {
+ if (overMnp) await transport.resumeRoot(groupId, rootName, signFn);
+ else if (overLoopback) await platform.node.op('resumeRoot', { groupId, rootName });
+ else throw new Error(t('node.root_no_route'));
+ }), [overMnp, overLoopback, transport, groupId, signFn, run]);
+
const doRemoveRoot = useCallback(async (rootName) => {
if (isLocal) {
if (onLocalRootsChange) {
@@ -321,6 +334,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
<span class="node-root-badge node-root-badge-warn">${t('group.root_ejected')}</span>`}
${!isLocal && r.available === false && !r.ejected && html`
<span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`}
+ ${!isLocal && r.paused && html`
+ <span class="node-root-badge">${t('node.root_paused')}</span>`}
</td>
${hasPaths && html`
<td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td>`}
@@ -347,6 +362,14 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn,
`}
<td class="sdt-col-actions">
${canEdit && !isLocal && html`
+ <button class="sdt-action-btn" disabled=${busy || !!r.ejected}
+ title=${r.paused ? t('node.root_resume') : t('node.root_pause')}
+ aria-label=${r.paused ? t('node.root_resume') : t('node.root_pause')}
+ onClick=${() => r.paused ? doResumeRoot(r.name) : doPauseRoot(r.name)}>
+ <${Icon} name=${r.paused ? 'play' : 'pause'} />
+ </button>
+ `}
+ ${canEdit && !isLocal && html`
<button class="sdt-action-btn" disabled=${busy || !r.removable}
title=${r.ejected ? t('group.root_plug') : t('group.root_eject')}
onClick=${() => r.ejected ? doPlugRoot(r.name) : doEjectRoot(r.name)}>
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
index bd4586b..ed81e7c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/index-dock-model.js
@@ -91,8 +91,9 @@ function loud(job, since, now) {
*
* `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.
+ * whether the operator hid it, which walk they paused, 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 = {};
@@ -105,9 +106,12 @@ export function dockRows(jobs, memory, now) {
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) {
+ // The node takes a moment to stop, and may go on to the next root
+ // queued: only the walk that was paused stays off screen.
+ const paused = prev.busy && prev.pausedSig === sig ? sig : '';
+ next[gid] = { busy: true, sig, since, shown, hidden, pausedSig: paused, doneAt: 0 };
+ if (shown && !hidden && !paused) rows.push({ ...job, state: 'running' });
+ } else if (prev.busy && prev.shown && !prev.hidden && !prev.pausedSig) {
next[gid] = { busy: false, doneAt: now };
rows.push({ ...job, state: 'done' });
} else if (prev.doneAt && now - prev.doneAt < DONE_MS) {
@@ -124,3 +128,14 @@ export function hideRow(memory, groupId) {
if (!prev || !prev.busy) return memory;
return { ...memory, [groupId]: { ...prev, hidden: true } };
}
+
+/**
+ * The operator paused the walk this row shows. Its row goes at once rather than
+ * when the node's next report arrives, and no "finished" follows it — the root
+ * is not finished, it is paused, and its settings row says so.
+ */
+export function pauseRow(memory, groupId) {
+ const prev = memory[groupId];
+ if (!prev || !prev.busy) return memory;
+ return { ...memory, [groupId]: { ...prev, pausedSig: prev.sig } };
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
index 6c89eb6..47a5818 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
@@ -6,7 +6,7 @@ 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,
+ fromLoopback, fromPush, mergeActivity, dockRows, hideRow, pauseRow, percent, queuedCount,
} from './index-dock-model.js';
// ── Indexing dock ───────────────────────────────────────────────────────────
@@ -29,6 +29,9 @@ const ABSENT_POLL_MS = 30000;
// 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();
+// How to pause a root of that group over the page's connection, signed by its
+// operator — the dock holds no transport of its own.
+const pushedPause = new Map();
const listeners = new Set();
/**
@@ -36,11 +39,17 @@ const listeners = new Set();
* 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.
+ * more, and a stale "scanning" would stay on screen for ever. `pause(rootName)`
+ * is what the row's pause button calls when no node answers on this machine.
*/
-export function reportIndexPush(groupId, status, roots) {
- if (status) pushed.set(groupId, fromPush(groupId, status, roots));
- else pushed.delete(groupId);
+export function reportIndexPush(groupId, status, roots, pause) {
+ if (status) {
+ pushed.set(groupId, fromPush(groupId, status, roots));
+ if (pause) pushedPause.set(groupId, pause);
+ } else {
+ pushed.delete(groupId);
+ pushedPause.delete(groupId);
+ }
for (const fn of listeners) fn();
}
@@ -96,7 +105,7 @@ function usePushedActivity() {
return snapshot;
}
-function DockRow({ row, onOpen, onHide }) {
+function DockRow({ row, error, onOpen, onPause, onHide }) {
const done = row.state === 'done';
const waiting = !done && !row.scanning;
const listing = !done && row.scanning && !row.totalBytes;
@@ -109,7 +118,9 @@ function DockRow({ row, onOpen, onHide }) {
const where = row.root ? `${row.groupName} › ${row.root}` : row.groupName;
let meta = '';
- if (listing) {
+ if (error) {
+ meta = error;
+ } else if (listing) {
meta = t('indexdock.listing');
} else if (!done && !waiting) {
meta = [
@@ -133,7 +144,8 @@ function DockRow({ row, onOpen, onHide }) {
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>`}
+ ${meta && html`<span class="index-dock-meta ${error ? 'index-dock-error' : ''}"
+ role=${error ? 'alert' : null}>${meta}</span>`}
</span>
<span class="index-progress-bar index-dock-bar">
<span class="index-progress-fill ${moving ? 'index-dock-indeterminate' : ''}"
@@ -141,6 +153,12 @@ function DockRow({ row, onOpen, onHide }) {
</span>
${next && html`<span class="index-dock-next">${next}</span>`}
</button>
+ ${!done && onPause && html`
+ <button type="button" class="index-dock-hide" title=${t('indexdock.pause')}
+ aria-label=${t('indexdock.pause')} onClick=${onPause}>
+ <${Icon} name="pause" />
+ </button>
+ `}
${!done && html`
<button type="button" class="index-dock-hide" title=${t('indexdock.hide')}
aria-label=${t('indexdock.hide')} onClick=${onHide}>
@@ -156,6 +174,7 @@ export function IndexingDock({ groups }) {
const remote = usePushedActivity();
const memory = useRef({});
const [, setTick] = useState(0);
+ const [errors, setErrors] = useState({});
// 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');
@@ -173,11 +192,36 @@ export function IndexingDock({ groups }) {
return () => clearInterval(id);
}, [pending]);
+ // A whole-root walk names its root; a burst of file events does not, and
+ // there is nothing to pause. The local node first, as for the figures.
+ const pauser = (row) => {
+ if (row.state !== 'running' || !row.scanning || !row.root) return null;
+ if (local[row.groupId]) {
+ return (rootName) => platform.node.op('pauseRoot', { groupId: row.groupId, rootName });
+ }
+ return pushedPause.get(row.groupId) || null;
+ };
+
+ const pause = async (row, via) => {
+ const before = memory.current;
+ memory.current = pauseRow(memory.current, row.groupId);
+ setErrors((e) => ({ ...e, [row.groupId]: '' }));
+ setTick((n) => n + 1);
+ try {
+ await via(row.root);
+ } catch (err) {
+ // Still running: put the row back, and say why.
+ memory.current = { ...memory.current, [row.groupId]: before[row.groupId] };
+ setErrors((e) => ({ ...e, [row.groupId]: platform.bridgeMessage(err) }));
+ }
+ };
+
if (!rows.length) return null;
return html`
<div class="index-dock" ref=${band}>
${rows.map((row) => html`
- <${DockRow} key=${row.groupId} row=${row}
+ <${DockRow} key=${row.groupId} row=${row} error=${errors[row.groupId] || ''}
+ onPause=${pauser(row) && (() => pause(row, pauser(row)))}
onOpen=${() => {
session.openGroupTab = { groupId: row.groupId, tab: 'settings' };
navigate('/group/' + row.groupId);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index c190efa..264cd5b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -880,6 +880,7 @@ export default {
'indexdock.next_count': "Wartend: {count}",
'indexdock.open': "Einstellungen dieser Gruppe öffnen",
'indexdock.hide': "Bis zur nächsten Indizierung ausblenden",
+ 'indexdock.pause': "Indexierung dieses Ordners pausieren",
'chat.load_older': '{n} ältere Nachrichten laden',
'chat.start_of_history': 'Anfang des Gesprächs',
'chat.today': 'Heute',
@@ -1001,6 +1002,9 @@ export default {
'node.remove_root': 'Entfernen',
'node.root_no_signing_key': 'Kein Signaturschlüssel verfügbar — koppeln Sie dieses Gerät zuerst mit dem Node',
'node.root_no_route': 'Keine Verbindung zum Node — verbinden Sie sich damit oder verwenden Sie die App auf dem Rechner, der ihn hostet',
+ 'node.root_pause': "Indexierung pausieren",
+ 'node.root_resume': "Indexierung fortsetzen",
+ 'node.root_paused': "Indexierung pausiert",
'node.root_remove_last': 'Eine Gruppe braucht mindestens ein Verzeichnis',
'node.root_path': 'Pfad',
'node.directory': 'Verzeichnis',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index df85791..a2d7699 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -993,6 +993,7 @@ export default {
'indexdock.next_count': "{count} more waiting",
'indexdock.open': "Open this group's settings",
'indexdock.hide': "Hide until the next indexing",
+ 'indexdock.pause': "Pause indexing this folder",
'chat.load_older': 'Load {n} older messages',
'chat.start_of_history': 'Beginning of the conversation',
'chat.today': 'Today',
@@ -1069,6 +1070,9 @@ export default {
'node.remove_root': 'Remove',
'node.root_no_signing_key': 'No signing key available — pair this device with the node first',
'node.root_no_route': 'No route to the node — connect to it, or use the app on the machine hosting it',
+ 'node.root_pause': "Pause indexing",
+ 'node.root_resume': "Resume indexing",
+ 'node.root_paused': "indexing paused",
'node.root_remove_last': 'A group needs at least one directory',
'node.root_path': 'Path',
'node.root_added': 'Directory added.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 50c08cc..e7d313e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -874,6 +874,7 @@ export default {
'indexdock.next_count': "En espera: {count}",
'indexdock.open': "Abrir los ajustes de este grupo",
'indexdock.hide': "Ocultar hasta la próxima indexación",
+ 'indexdock.pause': "Pausar la indexación de esta carpeta",
'chat.load_older': 'Cargar {n} mensajes anteriores',
'chat.start_of_history': 'Inicio de la conversación',
'chat.today': 'Hoy',
@@ -995,6 +996,9 @@ export default {
'node.remove_root': 'Eliminar',
'node.root_no_signing_key': 'No hay clave de firma disponible: empareja primero este dispositivo con el nodo',
'node.root_no_route': 'No hay ruta al nodo: conéctate a él o usa la aplicación en la máquina que lo aloja',
+ 'node.root_pause': "Pausar la indexación",
+ 'node.root_resume': "Reanudar la indexación",
+ 'node.root_paused': "indexación en pausa",
'node.root_remove_last': 'Un grupo necesita al menos un directorio',
'node.root_path': 'Ruta',
'node.directory': 'Directorio',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index a56c9ef..e1994dd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -877,6 +877,7 @@ export default {
'indexdock.next_count': "En attente : {count}",
'indexdock.open': "Ouvrir les paramètres de ce groupe",
'indexdock.hide': "Masquer jusqu'à la prochaine indexation",
+ 'indexdock.pause': "Mettre en pause l'indexation de ce dossier",
'chat.load_older': 'Charger {n} messages plus anciens',
'chat.start_of_history': 'Début de la conversation',
'chat.today': 'Aujourd’hui',
@@ -1004,6 +1005,9 @@ export default {
'node.remove_root': 'Retirer',
'node.root_no_signing_key': 'Aucune clé de signature disponible — appairez d\'abord cet appareil avec le nœud',
'node.root_no_route': 'Aucune route vers le nœud — connectez-vous à lui, ou utilisez l\'application sur la machine qui l\'héberge',
+ 'node.root_pause': "Mettre l'indexation en pause",
+ 'node.root_resume': "Reprendre l'indexation",
+ 'node.root_paused': "indexation en pause",
'node.root_remove_last': 'Un groupe a besoin d\'au moins un répertoire',
'node.root_path': 'Chemin',
'node.root_added': 'Répertoire ajouté.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index de071ee..eb96a49 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -876,6 +876,7 @@ export default {
'indexdock.next_count': "In attesa: {count}",
'indexdock.open': "Apri le impostazioni di questo gruppo",
'indexdock.hide': "Nascondi fino alla prossima indicizzazione",
+ 'indexdock.pause': "Sospendi l'indicizzazione di questa cartella",
'chat.load_older': 'Carica {n} messaggi precedenti',
'chat.start_of_history': 'Inizio della conversazione',
'chat.today': 'Oggi',
@@ -1003,6 +1004,9 @@ export default {
'node.remove_root': 'Rimuovi',
'node.root_no_signing_key': 'Nessuna chiave di firma disponibile: associa prima questo dispositivo al nodo',
'node.root_no_route': 'Nessuna via verso il nodo: connettiti a esso oppure usa l\'applicazione sulla macchina che lo ospita',
+ 'node.root_pause': "Sospendi l'indicizzazione",
+ 'node.root_resume': "Riprendi l'indicizzazione",
+ 'node.root_paused': "indicizzazione sospesa",
'node.root_remove_last': 'Un gruppo ha bisogno di almeno una directory',
'node.root_path': 'Percorso',
'node.directory': 'Directory',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index d7261c6..2a4584a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -864,6 +864,7 @@ export default {
'indexdock.next_count': "待機中: {count}",
'indexdock.open': "このグループの設定を開く",
'indexdock.hide': "次のインデックスまで隠す",
+ 'indexdock.pause': "このフォルダーのインデックス作成を一時停止",
'chat.load_older': '以前のメッセージを {n} 件読み込む',
'chat.start_of_history': '会話のはじまり',
'chat.today': '今日',
@@ -989,6 +990,9 @@ export default {
'node.remove_root': '削除',
'node.root_no_signing_key': '署名鍵がありません — 先にこの端末をノードとペアリングしてください',
'node.root_no_route': 'ノードへの経路がありません — 接続するか、ノードを動かしているマシンでアプリを使ってください',
+ 'node.root_pause': "インデックス作成を一時停止",
+ 'node.root_resume': "インデックス作成を再開",
+ 'node.root_paused': "インデックス作成を一時停止中",
'node.root_remove_last': 'グループには少なくとも 1 つのディレクトリが必要です',
'node.root_path': 'パス',
'node.directory': 'ディレクトリ',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index a628fb9..ce4acbe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -878,6 +878,7 @@ export default {
'indexdock.next_count': "Wachtend: {count}",
'indexdock.open': "Instellingen van deze groep openen",
'indexdock.hide': "Verbergen tot de volgende indexering",
+ 'indexdock.pause': "Indexeren van deze map pauzeren",
'chat.load_older': '{n} oudere berichten laden',
'chat.start_of_history': 'Begin van het gesprek',
'chat.today': 'Vandaag',
@@ -1005,6 +1006,9 @@ export default {
'node.remove_root': 'Verwijderen',
'node.root_no_signing_key': 'Geen ondertekeningssleutel beschikbaar — koppel dit apparaat eerst aan de node',
'node.root_no_route': 'Geen route naar de node — maak verbinding, of gebruik de app op de machine die hem host',
+ 'node.root_pause': "Indexeren pauzeren",
+ 'node.root_resume': "Indexeren hervatten",
+ 'node.root_paused': "indexeren gepauzeerd",
'node.root_remove_last': 'Een groep heeft minstens één map nodig',
'node.root_path': 'Pad',
'node.directory': 'Map',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 9014664..72ace3a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -898,6 +898,7 @@ export default {
'indexdock.next_count': "W kolejce: {count}",
'indexdock.open': "Otwórz ustawienia tej grupy",
'indexdock.hide': "Ukryj do następnego indeksowania",
+ 'indexdock.pause': "Wstrzymaj indeksowanie tego folderu",
'chat.load_older': 'Wczytaj {n} starszych wiadomości',
'chat.start_of_history': 'Początek rozmowy',
'chat.today': 'Dziś',
@@ -1029,6 +1030,9 @@ export default {
'node.remove_root': 'Usuń',
'node.root_no_signing_key': 'Brak klucza podpisu — najpierw sparuj to urządzenie z węzłem',
'node.root_no_route': 'Brak połączenia z węzłem — połącz się z nim albo użyj aplikacji na komputerze, który go hostuje',
+ 'node.root_pause': "Wstrzymaj indeksowanie",
+ 'node.root_resume': "Wznów indeksowanie",
+ 'node.root_paused': "indeksowanie wstrzymane",
'node.root_remove_last': 'Grupa wymaga co najmniej jednego katalogu',
'node.root_path': 'Ścieżka',
'node.directory': 'Katalog',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index e6972fb..ad46dc2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -875,6 +875,7 @@ export default {
'indexdock.next_count': "Na fila: {count}",
'indexdock.open': "Abrir as configurações deste grupo",
'indexdock.hide': "Ocultar até a próxima indexação",
+ 'indexdock.pause': "Pausar a indexação desta pasta",
'chat.load_older': 'Carregar {n} mensagens anteriores',
'chat.start_of_history': 'Início da conversa',
'chat.today': 'Hoje',
@@ -996,6 +997,9 @@ export default {
'node.remove_root': 'Remover',
'node.root_no_signing_key': 'Nenhuma chave de assinatura disponível — pareie este dispositivo com o nó primeiro',
'node.root_no_route': 'Sem rota até o nó — conecte-se a ele ou use o aplicativo na máquina que o hospeda',
+ 'node.root_pause': "Pausar a indexação",
+ 'node.root_resume': "Retomar a indexação",
+ 'node.root_paused': "indexação pausada",
'node.root_remove_last': 'Um grupo precisa de pelo menos um diretório',
'node.root_path': 'Caminho',
'node.directory': 'Diretório',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 96f7f5f..ccaf059 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -852,6 +852,7 @@ export default {
'indexdock.next_count': "等待中:{count}",
'indexdock.open': "打开此群组的设置",
'indexdock.hide': "隐藏,直到下一次索引",
+ 'indexdock.pause': "暂停索引此文件夹",
'chat.load_older': '加载更早的 {n} 条消息',
'chat.start_of_history': '对话开头',
'chat.today': '今天',
@@ -977,6 +978,9 @@ export default {
'node.remove_root': '移除',
'node.root_no_signing_key': '没有可用的签名密钥 — 请先将本设备与节点配对',
'node.root_no_route': '无法连接到节点 — 请先连接,或在运行该节点的机器上使用应用',
+ 'node.root_pause': "暂停索引",
+ 'node.root_resume': "继续索引",
+ 'node.root_paused': "索引已暂停",
'node.root_remove_last': '每个群组至少需要一个目录',
'node.root_path': '路径',
'node.directory': '目录',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 67d52b9..f6cb57c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -3591,6 +3591,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.sdt-action-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text); }
.sdt-action-btn:disabled { opacity: 0.3; cursor: default; }
.sdt-action-danger:hover:not(:disabled) { color: var(--error); border-color: var(--error); }
+.sdt-action-btn .icon { width: 1em; height: 1em; display: block; }
.sdt-dir-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
.sdt-dir-name .icon { width: 16px; height: 16px; flex-shrink: 0; }
.sdt-row-ejected { opacity: 0.5; }
@@ -4832,6 +4833,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
+.index-dock-meta.index-dock-error { color: var(--error); white-space: normal; }
.index-dock-bar { display: block; margin: 6px 0 0; }
.index-dock-bar .index-progress-fill { display: block; }
.index-dock-done .index-progress-fill { background: var(--success); }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
index 7bc77af..aea156b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
@@ -279,6 +279,27 @@ extendTransport(class {
return msg;
}
+ // Stop / restart indexing one root (MNP 6.2). Already-indexed files stay
+ // listed while it is paused.
+ async pauseRoot(groupId, rootName, signFn) {
+ return this._rootIndexOp('root_pause', groupId, rootName, signFn);
+ }
+
+ async resumeRoot(groupId, rootName, signFn) {
+ return this._rootIndexOp('root_resume', groupId, rootName, signFn);
+ }
+
+ async _rootIndexOp(type, groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type, v: '6.2', group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, type, rootName, signFn);
+ }
+ return msg;
+ }
+
/**
* Ask the node for a one-time pairing code admitting `userId` to this group.
*
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index ee6645f..c86d668 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -196,7 +196,7 @@ function _aborted() {
// unsaved-looking draft after a save that had worked.
const BROADCAST_ACK_TYPES = new Set([
'root_eject_ack', 'root_plug_ack', 'root_remove_ack',
- 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
+ 'root_pause_ack', 'root_resume_ack', 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
'chat_epoch_ack', 'chat_purge_ack', 'search_listed_ack',
]);
@@ -223,7 +223,7 @@ const ADMIN_OP_TYPES = new Set([
'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled',
'musicbrainz_enabled', 'file_delete', 'dir_delete',
'apps_enabled', 'set_scan_settings', 'member_revoke',
- 'root_remove', 'root_eject', 'root_plug',
+ 'root_remove', 'root_eject', 'root_plug', 'root_pause', 'root_resume',
'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch',
'chat_purge', 'search_listed', 'invite_create', 'invite_link_create', 'invite_cancel',
]);
@@ -2141,11 +2141,13 @@ class MeshBayTransport {
this._onMusicbrainzEnabled(Boolean(msg.enabled));
}
- // A root was ejected, plugged or removed. Broadcast by the node to every
- // peer, so everyone's table updates without waiting for the next index_sync.
+ // A root was ejected, plugged, paused, resumed or removed. Broadcast by the
+ // node to every peer, so everyone's table updates without waiting for the
+ // next index_sync.
// A root added or a flag changed — on the node's own machine, never over
// MNP — arrives in the index_delta the node pushes.
if (msg.type === 'root_eject_ack' || msg.type === 'root_plug_ack'
+ || msg.type === 'root_pause_ack' || msg.type === 'root_resume_ack'
|| msg.type === 'root_remove_ack') {
if (this._onRootsChanged) this._onRootsChanged(msg);
}
diff --git a/packages/meshbay-hub/tests/test_indexing_dock.py b/packages/meshbay-hub/tests/test_indexing_dock.py
index e960950..1619b57 100644
--- a/packages/meshbay-hub/tests/test_indexing_dock.py
+++ b/packages/meshbay-hub/tests/test_indexing_dock.py
@@ -51,15 +51,18 @@ def _job(**over) -> dict:
return job
-def _frames(tmp_path, frames: list[tuple[int, list[dict]]], hide_at: int | None = None):
+def _frames(tmp_path, frames: list[tuple[int, list[dict]]], hide_at: int | None = None,
+ pause_at: int | None = None):
"""Feed dockRows a sequence of (time ms, jobs) and return each frame's rows."""
return _run(tmp_path, f"""
const frames = {json.dumps(frames)};
const hideAt = {json.dumps(hide_at)};
+ const pauseAt = {json.dumps(pause_at)};
let memory = {{}};
const out = [];
for (const [now, jobs] of frames) {{
if (hideAt === now) memory = m.hideRow(memory, 'g1');
+ if (pauseAt === now) memory = m.pauseRow(memory, 'g1');
const r = m.dockRows(jobs, memory, now);
memory = r.memory;
out.push(r.rows.map((row) => [row.groupId, row.state]));
@@ -120,6 +123,24 @@ def test_a_hidden_row_stays_hidden_until_the_group_is_idle(tmp_path):
# ── Where it comes from ─────────────────────────────────────────────────────
@needs_node
+def test_a_paused_walk_leaves_at_once_and_is_not_called_finished(tmp_path):
+ """The node takes a moment to stop: its next reports still say scanning."""
+ idle = _job(scanning=False, kind="")
+ assert _frames(tmp_path, [
+ (0, [_job()]), (1000, [_job()]), (2000, [_job()]), (3000, [idle]),
+ ], pause_at=1000) == [[["g1", "running"]], [], [], []]
+
+
+@needs_node
+def test_pausing_one_root_does_not_hide_the_next_one_queued(tmp_path):
+ first = _job(root="results", queued=["archive"])
+ second = _job(root="archive")
+ assert _frames(tmp_path, [
+ (0, [first]), (1000, [first]), (2000, [second]),
+ ], pause_at=1000) == [[["g1", "running"]], [], [["g1", "running"]]]
+
+
+@needs_node
def test_the_loopback_answer_wins_and_the_hub_names_the_group(tmp_path):
out = _run(tmp_path, """
const local = { g1: m.fromLoopback({ group_id: 'g1', group_name: 'node name',
@@ -173,6 +194,17 @@ def test_only_an_operator_connection_feeds_the_dock():
"a member's connection must keep the sidebar dot only")
+def test_the_pause_button_reaches_the_node_by_either_door():
+ """The local node over loopback; any other through the page's connection,
+ signed by its operator — the dock holds no transport of its own."""
+ dock = DOCK.read_text(encoding="utf-8")
+ assert "node.op('pauseRoot'" in dock
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ assert "transport.pauseRoot(groupId, rootName, transport.signFn)" in page
+ for at in [m.start() for m in re.finditer(r"reportIndexPush\(groupId, (?!null)", page)]:
+ assert "pauseFromDock" in page[at:at + 140]
+
+
def test_leaving_a_group_clears_its_row():
page = GROUP_PAGE.read_text(encoding="utf-8")
cleanup = page[page.index(" return () => {\n cancelled = true;"):]