summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 21:55:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 21:55:20 +0200
commitb3709ac4d362987a9d025616c95065ceed0d216b (patch)
tree32e0cc5cc2775eddf516d114fa9799347a214bda /packages/meshbay-hub
parent012ba5b0cb8c556ce773423ca38d5184b74659ac (diff)
downloadmeshbay-b3709ac4d362987a9d025616c95065ceed0d216b.tar.gz
feat(node): persistent index cache, visible scan progress, adaptive reconcile, and delta sync
Indexer performance work, in four parts: - Persistent (path, size, mtime) -> hash cache (indexer/cache.py) so a node restart no longer re-hashes every file — measured at 23 minutes for a 114 GB library on a slow disk before this, near-instant after. Hashing is deliberately kept sequential (max_workers=1): it was never actually concurrent despite the pool size, and two interleaved reads seek-thrash a spinning disk instead of going faster. - Byte-based scan progress (IndexProgress), surfaced via the loopback index-status route, the handshake ack, and a periodic INDEX_PROGRESS push to connected peers — drives a progress bar in the Create Group wizard and "add a directory" in Settings, and an animated presence dot. Guaranteed to settle back to idle via try/finally and a final push on the scanning->false transition. - The reconcile backstop's directory walks now run in the executor instead of blocking the daemon's event loop; its interval defaults to 10 min (was 60s) with adaptive backoff to 2h when nothing changes, reset on a real change or a peer connecting, and is now a per-group operator setting (signed op + group Settings UI). - INDEX_DELTA wired up (protocol support existed, nothing called it): _on_index_change now sends additions/deletions instead of rebuilding the full entries list, coalesced over a short window so a burst of file events produces one push, and the hub swarm registration for public groups only (re-)registers newly added hashes. Also fixes several bugs found while testing the above against real libraries (a 114 GB and a 100+ GB group on a USB HDD): - /api/reload blocked until the reload — including a brand-new group's full initial scan — finished, which the Electron bridge's fixed 30s call timeout turned into a hard failure on any real library. The route now fires the reload without waiting (ops.start_reload), matching add_root/remove_root's existing pattern; the wizard's own step order was fixed to wait for the group to actually appear hosted before the steps that need it (extra roots, GEK), with retries for the residual race between that and the daemon's own bookkeeping. - transport.js's hand-rolled msgpack codec had no case for uint64/int64 (0xcf/0xd3) and crashed decoding any message containing one — hit by IndexProgress.scanned_bytes/total_bytes for any group over ~4.3 GB. Verified against real msgpack-encoded bytes from the Python side. - chat_hist_resp, and this change's own index_progress and set_scan_settings_ack pushes, were not routed by message type and could be handed to an unrelated pending request by the transport's "oldest pending" fallback, stalling it until its own 30s timeout and corrupting whatever received the wrong reply in its place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js111
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css59
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js119
16 files changed, 594 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index d18d9a4..044457b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -298,7 +298,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
// ── Sidebar ──────────────────────────────────────────────────────────────────
-function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) {
+function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey }) {
const isStaff = role === 'moderator' || role === 'admin';
return html`
<aside class="sidebar ${menuOpen ? 'open' : ''}">
@@ -339,13 +339,16 @@ function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) {
// actually cares about. Anything else is "not known yet".
const state = presence[g.id] ?? (g.node_online === true ? 'online'
: g.node_online === false ? 'offline' : 'unknown');
+ const label = state === 'indexing'
+ ? t('presence.indexing', { pct: indexProgressPct[g.id] ?? 0 })
+ : t('presence.' + state);
return html`
<a key=${g.id}
class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}"
href="#/group/${g.id}">
<span class="presence presence-${state}"
- title="${t('presence.' + state)}"
- aria-label="${t('presence.' + state)}"></span>
+ title="${label}"
+ aria-label="${label}"></span>
<span class="sidebar-item-name">${g.name}</span>
</a>
`;
@@ -840,6 +843,9 @@ function CreateGroupWizard({ token, username, onCreated }) {
const [setupSteps, setSetupSteps] = useState([]);
const [setupError, setSetupError] = useState('');
const [groupId, setGroupId] = useState('');
+ // Bytes-based, not file-count-based: one 20 GB file finishing last must
+ // not read as "8 of 9 done" while it is still the only thing running.
+ const [indexProgress, setIndexProgress] = useState(null);
const linkNodeKey = useCallback(async (pk) => {
if (!pk) return;
@@ -908,12 +914,14 @@ function CreateGroupWizard({ token, username, onCreated }) {
const steps = [
{ label: t('wizard.step_create_hub'), status: 'pending' },
{ label: t('wizard.step_attach'), status: 'pending' },
+ { label: t('wizard.step_index'), status: 'pending' },
];
if (roots.length > 1)
steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
steps.push({ label: t('wizard.step_gek'), status: 'pending' });
steps.push({ label: t('wizard.step_pair'), status: 'pending' });
setSetupSteps([...steps]);
+ setIndexProgress(null);
let si = 0;
const update = (status) => {
@@ -922,6 +930,27 @@ function CreateGroupWizard({ token, username, onCreated }) {
};
const advance = () => { si++; };
+ // Every step from here on is scoped to the group the node just attached.
+ // The node hot-loads a brand-new group synchronously — scan included —
+ // before it is added to groups_ctx or its own in-memory config
+ // (daemon.py _reload_config_inner: the config swap is the *last* thing
+ // that function does, a beat after the scan, not atomic with it) — so a
+ // call that lands in that beat gets refused even though the wait above
+ // already reported the scan as done. A handful of short retries absorbs
+ // that gap without a real cross-process synchronization primitive.
+ const withRetry = async (fn, attempts = 5, delayMs = 400) => {
+ for (let i = 0; i < attempts; i++) {
+ try {
+ return await fn();
+ } catch (err) {
+ const msg = String((err && err.message) || '');
+ const notHostedYet = /not configured on this node|not hosted on this node/i.test(msg);
+ if (!notHostedYet || i === attempts - 1) throw err;
+ await new Promise((r) => setTimeout(r, delayMs));
+ }
+ }
+ };
+
try {
// 1. Create group on hub
update('running');
@@ -942,32 +971,48 @@ function CreateGroupWizard({ token, username, onCreated }) {
attachBody.upload_dir = mainRoot.path;
}
await platform.node.call('POST', '/api/groups/attach', attachBody);
+ // Fire-and-forget on the node's side (ui/app.py) — this call itself
+ // returns immediately, well before the scan below finishes. It used
+ // to be the thing the wizard waited on, which is exactly what made
+ // "Attaching to node" time out on a real library (see ops.start_reload).
await platform.node.call('POST', '/api/reload');
update('done');
advance();
- // 3. Add extra roots (if >1)
+ // 3. Wait for the node's own initial scan of this group to finish —
+ // the group is not usable for anything below (extra roots, GEK) until
+ // this finishes, so nobody lands on a page that looks broken, or hits
+ // a "not configured" error from racing ahead of it. Can take tens of
+ // minutes on a slow disk (see the StarWars benchmark) — the node
+ // keeps scanning on its own either way (test_hot_reload_survives_
+ // client_close.py); this step is only about not lying about it.
+ update('running');
+ await platform.waitForGroupHosted(gid, setIndexProgress);
+ update('done');
+ advance();
+
+ // 4. Add extra roots (if >1)
if (roots.length > 1) {
update('running');
for (let i = 0; i < roots.length; i++) {
if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue;
const r = roots[i];
- await platform.node.call('POST', `/api/groups/${gid}/roots`, {
+ await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, {
path: r.path, name: r.name,
upload: i === uploadIdx,
- });
+ }));
}
update('done');
advance();
}
- // 4. GEK init
+ // 5. GEK init
update('running');
- await platform.node.call('POST', `/api/groups/${gid}/gek`);
+ await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
update('done');
advance();
- // 5. Generate pairing code
+ // 6. Generate pairing code
update('running');
const pairResult = await platform.node.call('POST', '/api/operator/pair');
if (pairResult && pairResult.code) {
@@ -976,7 +1021,7 @@ function CreateGroupWizard({ token, username, onCreated }) {
}
update('done');
- // Reload node config so it picks up the new group
+ // Reload once more so any roots added at step 4 are picked up.
try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ }
setStep(3);
@@ -1105,6 +1150,9 @@ function CreateGroupWizard({ token, username, onCreated }) {
// Step 2: Automatic setup progress
if (step === 2) {
+ const pct = indexProgress && indexProgress.total_bytes
+ ? Math.min(100, Math.round(100 * indexProgress.scanned_bytes / indexProgress.total_bytes))
+ : 0;
return html`<div class="page-content">
<h2>${t('wizard.title')}</h2>
<p class="page-message">${t('wizard.setting_up')}</p>
@@ -1120,6 +1168,19 @@ function CreateGroupWizard({ token, username, onCreated }) {
</div>
`)}
</div>
+ ${indexProgress && indexProgress.scanning && html`
+ <div class="index-progress" style="margin-top:12px">
+ <div class="index-progress-bar">
+ <div class="index-progress-fill" style="width:${pct}%"></div>
+ </div>
+ <div class="index-progress-label">${t('wizard.indexing_progress', { pct })}</div>
+ ${indexProgress.current_dir && html`
+ <div class="index-progress-dir">
+ ${t('wizard.indexing_current_dir', { dir: indexProgress.current_dir })}
+ </div>
+ `}
+ </div>
+ `}
${setupError && html`
<div class="error-msg" style="margin-top:16px">${setupError}</div>
<div style="display:flex;gap:8px;margin-top:8px">
@@ -2835,8 +2896,15 @@ function App() {
// for the session only: it is a cache of observations, not a source of truth,
// and a reload should go back to asking.
const [presence, setPresence] = useState({});
- const notePresence = useCallback((gid, state) => {
+ // Percentage alongside 'indexing' presence — kept separate from `presence`
+ // itself so a changing % does not require treating every tick as a new
+ // presence state (see the Sidebar dot's title/aria-label).
+ const [indexProgressPct, setIndexProgressPct] = useState({});
+ const notePresence = useCallback((gid, state, pct) => {
setPresence(prev => (prev[gid] === state ? prev : { ...prev, [gid]: state }));
+ if (pct !== undefined) {
+ setIndexProgressPct(prev => (prev[gid] === pct ? prev : { ...prev, [gid]: pct }));
+ }
}, []);
const handleLeftGroup = useCallback((gid) => {
@@ -3084,6 +3152,7 @@ function App() {
${user && html`<${Sidebar}
groups=${groups}
presence=${presence}
+ indexProgressPct=${indexProgressPct}
route=${route}
menuOpen=${menuOpen}
role=${user.role}
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 1528213..29f3602 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -76,6 +76,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// every registered app when a node predates the setting (or hasn't answered
// yet), so nothing disappears for an existing group.
const [enabledApps, setEnabledApps] = useState(null);
+ // Reconcile interval / debounce currently in effect on the node — shown
+ // to the operator in Settings, not enforced from here (indexer.py owns
+ // that). Null until the handshake ack arrives.
+ const [scanSettings, setScanSettings] = useState(null);
// Paired ≠ operator account. `is_node_admin` says the hub account owning this
// node is the one connecting; this says the node pinned *this browser's* key
// as an operator key. Only the second one lets you sign an invite, and only
@@ -123,6 +127,25 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
}, [groupId, group]);
+ // additions/deletions only (daemon.py _broadcast_index_change, once there
+ // is a previous snapshot to diff against) — applied on top of whatever
+ // applyIndex last put in `entries`, instead of replacing the whole table
+ // for one changed file.
+ const applyIndexDelta = useCallback((deltaMsg) => {
+ setEntries((prev) => {
+ const deletions = new Set(deltaMsg.deletions || []);
+ const kept = prev.filter((e) => !deletions.has(e.id));
+ // The index is keyed by content hash: an addition whose id is already
+ // present is the same duplicate-content case indexer.py's own
+ // reconcile sweep leaves alone, not a second row for one file.
+ const keptIds = new Set(kept.map((e) => e.id));
+ const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id));
+ const fresh = kept.concat(additions);
+ cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
+ return fresh;
+ });
+ }, [groupId, group]);
+
useEffect(() => {
let cancelled = false;
@@ -172,11 +195,24 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setIsNodeAdmin(!!ack.is_node_admin);
setMemberUpload(ack.member_upload !== false);
setEnabledApps(ack.enabled_apps || null);
+ setScanSettings(ack.scan_settings || null);
// Changed while we are connected, by an operator who may be someone
// else entirely. Without this the button stays until a reconnection,
// and a button that is still there is a button people press.
transport.onUploadPolicy = (allowed) => setMemberUpload(allowed);
transport.onAppsEnabled = (apps) => setEnabledApps(apps);
+ // 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
+ // False transition (daemon.py _progress_pusher), so this always
+ // settles back to 'online' rather than getting stuck.
+ transport.onIndexProgress = (status) => {
+ if (cancelled || !onPresence) return;
+ const pct = status.total_bytes
+ ? Math.min(100, Math.round(100 * status.scanned_bytes / status.total_bytes))
+ : 0;
+ onPresence(groupId, status.scanning ? 'indexing' : 'online', pct);
+ };
setOperatorPaired(transport.memberRole === 'operator');
// A first join to this node generated an identity for it; leave it with
@@ -203,6 +239,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
if (cancelled) return;
applyIndex(msg);
};
+ transport.onIndexDelta = (msg) => {
+ if (cancelled) return;
+ applyIndexDelta(msg);
+ };
// We are in: an invitation to this group has served its purpose.
if (onJoined) onJoined(groupId);
@@ -214,7 +254,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
touchActivity();
// First-hand evidence, and the strongest available: this browser spoke
// to the node. It outranks whatever the hub said in the group list.
- if (onPresence) onPresence(groupId, 'online');
+ // A scan already under way at the moment of connecting (ack.indexing,
+ // webrtc_server.py _complete_handshake) shows as indexing right away
+ // rather than waiting for the next periodic push.
+ if (onPresence) {
+ const idx = ack.indexing;
+ if (idx && idx.scanning) {
+ const pct = idx.total_bytes
+ ? Math.min(100, Math.round(100 * idx.scanned_bytes / idx.total_bytes))
+ : 0;
+ onPresence(groupId, 'indexing', pct);
+ } else {
+ onPresence(groupId, 'online');
+ }
+ }
} catch (err) {
if (cancelled) return;
@@ -448,6 +501,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onMemberUpload=${(allowed) => setMemberUpload(allowed)}
enabledApps=${enabledApps}
onEnabledApps=${(keys) => setEnabledApps(keys)}
+ scanSettings=${scanSettings}
+ onScanSettings=${(s) => setScanSettings(s)}
onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
`}
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 a14fd21..0b64a03 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -22,6 +22,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
memberUpload, onMemberUpload,
enabledApps, onEnabledApps,
+ scanSettings, onScanSettings,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -36,6 +37,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const [nodeGroupName, setNodeGroupName] = useState('');
const [nodeBusy, setNodeBusy] = useState(false);
const [nodeMsg, setNodeMsg] = useState('');
+ // Bytes-based indexing progress while a newly added directory is being
+ // scanned — same source as the Create Group wizard's step, see
+ // platform.watchIndexProgress.
+ const [nodeIndexProgress, setNodeIndexProgress] = useState(null);
const loadNodeInfo = useCallback(async () => {
if (!platform.node.available) return;
@@ -194,6 +199,51 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onEnabledApps, activeApps]);
+ const [scanBusy, setScanBusy] = useState(false);
+ const [scanMsg, setScanMsg] = useState('');
+ const [reconcileMinutes, setReconcileMinutes] = useState(
+ scanSettings ? Math.round(scanSettings.reconcile_interval_secs / 60) : 10);
+ const [debounceSeconds, setDebounceSeconds] = useState(
+ scanSettings ? Math.round(scanSettings.debounce_secs) : 2);
+ // The node is the source of truth; once it has answered, the fields track
+ // it rather than whatever this browser guessed before connecting.
+ useEffect(() => {
+ if (!scanSettings) return;
+ setReconcileMinutes(Math.round(scanSettings.reconcile_interval_secs / 60));
+ setDebounceSeconds(Math.round(scanSettings.debounce_secs));
+ }, [scanSettings]);
+
+ /**
+ * How often the reconciliation backstop runs, and how long a changed file
+ * is left alone before being hashed. Same shape as toggleApp: signed, and
+ * the fields do not claim success until the node has confirmed it.
+ */
+ const saveScanSettings = useCallback(async () => {
+ const transport = transportRef && transportRef.current;
+ setScanMsg('');
+ setScanBusy(true);
+ try {
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node');
+ }
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ await transport.setScanSettings(reconcileMinutes * 60, debounceSeconds, signFn);
+ const applied = {
+ reconcile_interval_secs: reconcileMinutes * 60,
+ debounce_secs: debounceSeconds,
+ };
+ if (onScanSettings) onScanSettings(applied);
+ setScanMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setScanMsg(err.message);
+ } finally {
+ setScanBusy(false);
+ }
+ }, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]);
+
const [removing, setRemoving] = useState('');
/**
@@ -382,6 +432,37 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${/* How hard the node works watching its own disk — indexer.py
+ DirectoryIndexer. A performance knob, not a permission: it
+ changes nothing about who can see or do what. */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.scan_title')}</h3>
+ <p class="settings-hint">${t('settings_node.scan_hint')}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.scan_reconcile_label')}
+ <input type="number" min="1" max="1440" step="1"
+ value=${reconcileMinutes} disabled=${scanBusy}
+ onInput=${e => setReconcileMinutes(Number(e.target.value))} />
+ </label>
+ </div>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.scan_debounce_label')}
+ <input type="number" min="0" max="300" step="1"
+ value=${debounceSeconds} disabled=${scanBusy}
+ onInput=${e => setDebounceSeconds(Number(e.target.value))} />
+ </label>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${scanBusy} onClick=${saveScanSettings}>
+ ${scanBusy ? t('settings_node.scan_saving') : t('settings_node.scan_save')}
+ </button>
+ ${scanMsg && html`<p class="settings-hint">${scanMsg}</p>`}
+ </div>
+ `}
+
${/* Roots management (Electron-only, when node is local) */
nodeDetected && nodeRoots.length > 0 && html`
<div class="settings-section">
@@ -425,12 +506,18 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
onClick=${async () => {
const chosen = await platform.rootPicker.choose();
if (!chosen) return;
- setNodeBusy(true); setNodeMsg('');
+ setNodeBusy(true); setNodeMsg(''); setNodeIndexProgress(null);
try {
await platform.node.call('POST',
'/api/groups/' + groupId + '/roots',
{ path: chosen.path, name: chosen.name });
await platform.node.call('POST', '/api/reload');
+ // The root is already scanning in the background on the
+ // node regardless of whether anyone watches this — see
+ // the "closing the client" test in test_hot_reload_*.py.
+ // This is only about not leaving the operator staring at
+ // an unchanged screen while it happens.
+ await platform.watchIndexProgress(groupId, setNodeIndexProgress);
setNodeMsg(t('node.root_added'));
await loadNodeInfo();
} catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
@@ -438,6 +525,28 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}}>
<${Icon} name="folder-plus" /> ${t('node.add_root')}
</button>
+ ${nodeIndexProgress && nodeIndexProgress.scanning && html`
+ <div class="index-progress" style="margin-top:8px">
+ <div class="index-progress-bar">
+ <div class="index-progress-fill" style="width:${
+ nodeIndexProgress.total_bytes
+ ? Math.min(100, Math.round(
+ 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
+ : 0}%"></div>
+ </div>
+ <div class="index-progress-label">${t('wizard.indexing_progress', {
+ pct: nodeIndexProgress.total_bytes
+ ? Math.min(100, Math.round(
+ 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
+ : 0,
+ })}</div>
+ ${nodeIndexProgress.current_dir && html`
+ <div class="index-progress-dir">
+ ${t('wizard.indexing_current_dir', { dir: nodeIndexProgress.current_dir })}
+ </div>
+ `}
+ </div>
+ `}
</div>
</div>
`}
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 59127af..866212f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -457,6 +457,7 @@ export default {
'presence.online': 'Ein Node, der diese Gruppe hostet, ist online',
'presence.offline': 'Kein Node für diese Gruppe erreichbar',
'presence.unknown': 'Noch nicht geprüft',
+ 'presence.indexing': 'Indizierung läuft — {pct}%',
'chat.load_older': '{n} ältere Nachrichten laden',
'chat.start_of_history': 'Anfang des Gesprächs',
'chat.today': 'Heute',
@@ -564,6 +565,13 @@ export default {
// Settings (node)
'settings_node.detach_failed_continue': 'Die Gruppe konnte nicht vom Node getrennt werden. Trotzdem auf dem Hub löschen?',
'settings_node.roots': 'Freigegebene Verzeichnisse',
+ 'settings_node.scan_title': 'Scan',
+ 'settings_node.scan_hint': 'Wie oft der Node seine Verzeichnisse erneut auf verpasste Änderungen prüft und wie lange er nach einer Änderung wartet, bevor eine Datei indiziert wird.',
+ 'settings_node.scan_reconcile_label': 'Prüfintervall (Minuten)',
+ 'settings_node.scan_debounce_label': 'Wartezeit nach einer Änderung (Sekunden)',
+ 'settings_node.scan_save': 'Speichern',
+ 'settings_node.scan_saving': 'Wird gespeichert…',
+ 'settings_node.scan_saved': 'Gespeichert.',
// Create-group wizard
'wizard.title': 'Gruppe erstellen',
@@ -586,6 +594,9 @@ export default {
'wizard.step_add_roots': 'Verzeichnisse hinzufügen',
'wizard.step_gek': 'Verschlüsselungsschlüssel initialisieren',
'wizard.step_pair': 'Kopplung einrichten',
+ 'wizard.step_index': 'Dateien werden indiziert',
+ 'wizard.indexing_progress': 'Indizierung… {pct}%',
+ 'wizard.indexing_current_dir': 'Wird gescannt: {dir}',
'wizard.done_title': 'Gruppe erstellt',
'wizard.done_message': 'Ihre Gruppe ist bereit. Ihr Node hostet sie und die Verschlüsselung ist eingerichtet.',
'wizard.go_to_group': 'Zur Gruppe',
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 58590b1..a83b067 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -364,6 +364,9 @@ export default {
'wizard.step_add_roots': 'Adding directories',
'wizard.step_gek': 'Initializing encryption key',
'wizard.step_pair': 'Setting up pairing',
+ 'wizard.step_index': 'Indexing files',
+ 'wizard.indexing_progress': 'Indexing… {pct}%',
+ 'wizard.indexing_current_dir': 'Scanning: {dir}',
'wizard.finish_later': 'Finish setup later',
'wizard.done_title': 'Group created',
'wizard.done_message': 'Your group is ready. Your node is hosting it and encryption is set up.',
@@ -386,6 +389,13 @@ export default {
// Unified Group Settings (node sections)
'settings_node.roots': 'Shared directories',
'settings_node.detach_failed_continue': 'Could not detach the group from the node. Delete on hub anyway?',
+ 'settings_node.scan_title': 'Scanning',
+ 'settings_node.scan_hint': 'How often the node re-checks its directories for changes it may have missed, and how long it waits after a file changes before indexing it.',
+ 'settings_node.scan_reconcile_label': 'Re-check interval (minutes)',
+ 'settings_node.scan_debounce_label': 'Wait after a change (seconds)',
+ 'settings_node.scan_save': 'Save',
+ 'settings_node.scan_saving': 'Saving…',
+ 'settings_node.scan_saved': 'Saved.',
// Members
'members.col_role': 'Role',
@@ -486,6 +496,7 @@ export default {
'presence.online': 'A node serving this group is online',
'presence.offline': 'No node is reachable for this group',
'presence.unknown': 'Not checked yet',
+ 'presence.indexing': 'Indexing — {pct}% done',
'chat.load_older': 'Load {n} older messages',
'chat.start_of_history': 'Beginning of the conversation',
'chat.today': 'Today',
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 75a67b6..1621182 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -452,6 +452,7 @@ export default {
'presence.online': 'Un node que sirve este grupo está en línea',
'presence.offline': 'Ningún node accesible para este grupo',
'presence.unknown': 'Aún sin comprobar',
+ 'presence.indexing': 'Indexando — {pct}%',
'chat.load_older': 'Cargar {n} mensajes anteriores',
'chat.start_of_history': 'Inicio de la conversación',
'chat.today': 'Hoy',
@@ -559,6 +560,13 @@ export default {
// Node settings
'settings_node.detach_failed_continue': 'No se pudo desconectar el grupo del node. ¿Eliminar del hub de todos modos?',
'settings_node.roots': 'Directorios compartidos',
+ 'settings_node.scan_title': 'Escaneo',
+ 'settings_node.scan_hint': 'Con qué frecuencia el node vuelve a comprobar sus directorios en busca de cambios que pudo haber pasado por alto, y cuánto tiempo espera tras un cambio antes de indexar un archivo.',
+ 'settings_node.scan_reconcile_label': 'Intervalo de comprobación (minutos)',
+ 'settings_node.scan_debounce_label': 'Espera tras un cambio (segundos)',
+ 'settings_node.scan_save': 'Guardar',
+ 'settings_node.scan_saving': 'Guardando…',
+ 'settings_node.scan_saved': 'Guardado.',
// Create group wizard
'wizard.title': 'Crear grupo',
@@ -581,6 +589,9 @@ export default {
'wizard.step_add_roots': 'Añadiendo directorios',
'wizard.step_gek': 'Inicializando clave de cifrado',
'wizard.step_pair': 'Configurando emparejamiento',
+ 'wizard.step_index': 'Indexando archivos',
+ 'wizard.indexing_progress': 'Indexando… {pct}%',
+ 'wizard.indexing_current_dir': 'Analizando: {dir}',
'wizard.done_title': 'Grupo creado',
'wizard.done_message': 'Su grupo está listo. Su node lo aloja y el cifrado está configurado.',
'wizard.go_to_group': 'Ir al grupo',
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 d60c5d0..6e21a06 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -456,6 +456,7 @@ export default {
'presence.online': 'Un node servant ce groupe est en ligne',
'presence.offline': 'Aucun node joignable pour ce groupe',
'presence.unknown': 'Pas encore vérifié',
+ 'presence.indexing': 'Indexation en cours — {pct} %',
'chat.load_older': 'Charger {n} messages plus anciens',
'chat.start_of_history': 'Début de la conversation',
'chat.today': 'Aujourd’hui',
@@ -575,6 +576,13 @@ export default {
// Node settings
'settings_node.detach_failed_continue': 'Impossible de détacher le groupe du node. Supprimer quand même sur le hub ?',
'settings_node.roots': 'Répertoires partagés',
+ 'settings_node.scan_title': 'Analyse',
+ 'settings_node.scan_hint': 'À quelle fréquence le node revérifie ses répertoires à la recherche de changements manqués, et combien de temps il attend après une modification avant d\'indexer un fichier.',
+ 'settings_node.scan_reconcile_label': 'Intervalle de revérification (minutes)',
+ 'settings_node.scan_debounce_label': 'Attente après un changement (secondes)',
+ 'settings_node.scan_save': 'Enregistrer',
+ 'settings_node.scan_saving': 'Enregistrement…',
+ 'settings_node.scan_saved': 'Enregistré.',
// Create group wizard
'wizard.title': 'Créer un groupe',
@@ -597,6 +605,9 @@ export default {
'wizard.step_add_roots': 'Ajout des répertoires',
'wizard.step_gek': 'Initialisation de la clé de chiffrement',
'wizard.step_pair': 'Mise en place de l\'appariement',
+ 'wizard.step_index': 'Indexation des fichiers',
+ 'wizard.indexing_progress': 'Indexation… {pct} %',
+ 'wizard.indexing_current_dir': 'Analyse : {dir}',
'wizard.done_title': 'Groupe créé',
'wizard.done_message': 'Votre groupe est prêt. Votre node l\'héberge et le chiffrement est configuré.',
'wizard.go_to_group': 'Aller au groupe',
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 b65e2ce..edd15ab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -454,6 +454,7 @@ export default {
'presence.online': 'Un node che ospita questo gruppo è online',
'presence.offline': 'Nessun node raggiungibile per questo gruppo',
'presence.unknown': 'Non ancora verificato',
+ 'presence.indexing': 'Indicizzazione in corso — {pct}%',
'chat.load_older': 'Carica {n} messaggi precedenti',
'chat.start_of_history': 'Inizio della conversazione',
'chat.today': 'Oggi',
@@ -573,6 +574,13 @@ export default {
// Settings — node
'settings_node.detach_failed_continue': 'Impossibile scollegare il gruppo dal node. Eliminare comunque dal hub?',
'settings_node.roots': 'Directory condivise',
+ 'settings_node.scan_title': 'Scansione',
+ 'settings_node.scan_hint': 'Con quale frequenza il node ricontrolla le sue directory per cambiamenti che potrebbe aver perso, e quanto tempo attende dopo una modifica prima di indicizzare un file.',
+ 'settings_node.scan_reconcile_label': 'Intervallo di ricontrollo (minuti)',
+ 'settings_node.scan_debounce_label': 'Attesa dopo una modifica (secondi)',
+ 'settings_node.scan_save': 'Salva',
+ 'settings_node.scan_saving': 'Salvataggio…',
+ 'settings_node.scan_saved': 'Salvato.',
// Create-group wizard
'wizard.title': 'Crea gruppo',
@@ -595,6 +603,9 @@ export default {
'wizard.step_add_roots': 'Aggiunta delle directory',
'wizard.step_gek': 'Inizializzazione della chiave di cifratura',
'wizard.step_pair': 'Configurazione del pairing',
+ 'wizard.step_index': 'Indicizzazione dei file',
+ 'wizard.indexing_progress': 'Indicizzazione… {pct}%',
+ 'wizard.indexing_current_dir': 'Scansione: {dir}',
'wizard.done_title': 'Gruppo creato',
'wizard.done_message': 'Il suo gruppo è pronto. Il suo node lo ospita e la cifratura è configurata.',
'wizard.go_to_group': 'Vai al gruppo',
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 e965590..00fffed 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -442,6 +442,7 @@ export default {
'presence.online': 'このグループをホストする node が稼働中です',
'presence.offline': 'このグループに到達できる node がありません',
'presence.unknown': '未確認',
+ 'presence.indexing': 'インデックス中 — {pct}%',
'chat.load_older': '以前のメッセージを {n} 件読み込む',
'chat.start_of_history': '会話のはじまり',
'chat.today': '今日',
@@ -557,6 +558,13 @@ export default {
// Settings — node
'settings_node.detach_failed_continue': 'node からグループを切り離せませんでした。それでも hub 上で削除しますか?',
'settings_node.roots': '共有ディレクトリ',
+ 'settings_node.scan_title': 'スキャン',
+ 'settings_node.scan_hint': 'node が見逃した変更がないかディレクトリを再確認する頻度と、ファイルの変更後にインデックスするまで待つ時間。',
+ 'settings_node.scan_reconcile_label': '再確認の間隔(分)',
+ 'settings_node.scan_debounce_label': '変更後の待機時間(秒)',
+ 'settings_node.scan_save': '保存',
+ 'settings_node.scan_saving': '保存中…',
+ 'settings_node.scan_saved': '保存しました。',
// Wizard
'wizard.title': 'グループを作成',
@@ -579,6 +587,9 @@ export default {
'wizard.step_add_roots': 'ディレクトリを追加中',
'wizard.step_gek': '暗号化鍵を初期化中',
'wizard.step_pair': 'ペアリングをセットアップ中',
+ 'wizard.step_index': 'ファイルをインデックス中',
+ 'wizard.indexing_progress': 'インデックス中… {pct}%',
+ 'wizard.indexing_current_dir': 'スキャン中: {dir}',
'wizard.done_title': 'グループを作成しました',
'wizard.done_message': 'グループの準備ができました。node がホストし、暗号化が設定されています。',
'wizard.go_to_group': 'グループを開く',
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 b37be08..2837ebb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -456,6 +456,7 @@ export default {
'presence.online': 'Een node die deze groep host is online',
'presence.offline': 'Geen node bereikbaar voor deze groep',
'presence.unknown': 'Nog niet gecontroleerd',
+ 'presence.indexing': 'Bezig met indexeren — {pct}%',
'chat.load_older': '{n} oudere berichten laden',
'chat.start_of_history': 'Begin van het gesprek',
'chat.today': 'Vandaag',
@@ -575,6 +576,13 @@ export default {
// Node settings
'settings_node.detach_failed_continue': 'Kon de groep niet van de node loskoppelen. Toch op de hub verwijderen?',
'settings_node.roots': 'Gedeelde mappen',
+ 'settings_node.scan_title': 'Scannen',
+ 'settings_node.scan_hint': 'Hoe vaak de node zijn mappen opnieuw controleert op wijzigingen die zijn gemist, en hoe lang hij na een wijziging wacht voordat een bestand wordt geïndexeerd.',
+ 'settings_node.scan_reconcile_label': 'Controle-interval (minuten)',
+ 'settings_node.scan_debounce_label': 'Wachttijd na een wijziging (seconden)',
+ 'settings_node.scan_save': 'Opslaan',
+ 'settings_node.scan_saving': 'Bezig met opslaan…',
+ 'settings_node.scan_saved': 'Opgeslagen.',
// Create group wizard
'wizard.title': 'Groep aanmaken',
@@ -597,6 +605,9 @@ export default {
'wizard.step_add_roots': 'Mappen toevoegen',
'wizard.step_gek': 'Versleutelingssleutel initialiseren',
'wizard.step_pair': 'Koppeling instellen',
+ 'wizard.step_index': 'Bestanden indexeren',
+ 'wizard.indexing_progress': 'Bezig met indexeren… {pct}%',
+ 'wizard.indexing_current_dir': 'Scannen: {dir}',
'wizard.done_title': 'Groep aangemaakt',
'wizard.done_message': 'Uw groep is klaar. Uw node host de groep en de versleuteling is ingesteld.',
'wizard.go_to_group': 'Naar groep',
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 b445f79..760f29c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -469,6 +469,7 @@ export default {
'presence.online': 'Node hostujący tę grupę jest dostępny',
'presence.offline': 'Brak osiągalnego node dla tej grupy',
'presence.unknown': 'Jeszcze nie sprawdzono',
+ 'presence.indexing': 'Indeksowanie — {pct}%',
'chat.load_older': 'Wczytaj {n} starszych wiadomości',
'chat.start_of_history': 'Początek rozmowy',
'chat.today': 'Dziś',
@@ -596,6 +597,13 @@ export default {
// Settings — node
'settings_node.detach_failed_continue': 'Nie udało się odłączyć grupy od node. Usunąć mimo to na hub?',
'settings_node.roots': 'Katalogi współdzielone',
+ 'settings_node.scan_title': 'Skanowanie',
+ 'settings_node.scan_hint': 'Jak często node ponownie sprawdza swoje katalogi w poszukiwaniu pominiętych zmian oraz jak długo czeka po zmianie pliku przed jego zindeksowaniem.',
+ 'settings_node.scan_reconcile_label': 'Interwał sprawdzania (minuty)',
+ 'settings_node.scan_debounce_label': 'Oczekiwanie po zmianie (sekundy)',
+ 'settings_node.scan_save': 'Zapisz',
+ 'settings_node.scan_saving': 'Zapisywanie…',
+ 'settings_node.scan_saved': 'Zapisano.',
// Create-group wizard
'wizard.title': 'Utwórz grupę',
@@ -618,6 +626,9 @@ export default {
'wizard.step_add_roots': 'Dodawanie katalogów',
'wizard.step_gek': 'Inicjalizacja klucza szyfrowania',
'wizard.step_pair': 'Konfigurowanie parowania',
+ 'wizard.step_index': 'Indeksowanie plików',
+ 'wizard.indexing_progress': 'Indeksowanie… {pct}%',
+ 'wizard.indexing_current_dir': 'Skanowanie: {dir}',
'wizard.done_title': 'Grupa utworzona',
'wizard.done_message': 'Grupa jest gotowa. Node ją hostuje, a szyfrowanie jest skonfigurowane.',
'wizard.go_to_group': 'Przejdź do grupy',
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 c3220ae..0001051 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
@@ -453,6 +453,7 @@ export default {
'presence.online': 'Um node que hospeda este grupo está on-line',
'presence.offline': 'Nenhum node acessível para este grupo',
'presence.unknown': 'Ainda não verificado',
+ 'presence.indexing': 'Indexando — {pct}%',
'chat.load_older': 'Carregar {n} mensagens anteriores',
'chat.start_of_history': 'Início da conversa',
'chat.today': 'Hoje',
@@ -560,6 +561,13 @@ export default {
// Settings — node
'settings_node.detach_failed_continue': 'Não foi possível desanexar o grupo do node. Excluir do hub mesmo assim?',
'settings_node.roots': 'Diretórios compartilhados',
+ 'settings_node.scan_title': 'Varredura',
+ 'settings_node.scan_hint': 'Com que frequência o node reverifica seus diretórios em busca de mudanças que possa ter perdido, e quanto tempo espera após uma mudança antes de indexar um arquivo.',
+ 'settings_node.scan_reconcile_label': 'Intervalo de reverificação (minutos)',
+ 'settings_node.scan_debounce_label': 'Espera após uma mudança (segundos)',
+ 'settings_node.scan_save': 'Salvar',
+ 'settings_node.scan_saving': 'Salvando…',
+ 'settings_node.scan_saved': 'Salvo.',
// Create group wizard
'wizard.title': 'Criar grupo',
@@ -582,6 +590,9 @@ export default {
'wizard.step_add_roots': 'Adicionando diretórios',
'wizard.step_gek': 'Inicializando chave de criptografia',
'wizard.step_pair': 'Configurando pareamento',
+ 'wizard.step_index': 'Indexando arquivos',
+ 'wizard.indexing_progress': 'Indexando… {pct}%',
+ 'wizard.indexing_current_dir': 'Analisando: {dir}',
'wizard.done_title': 'Grupo criado',
'wizard.done_message': 'Seu grupo está pronto. Seu node o está hospedando e a criptografia está configurada.',
'wizard.go_to_group': 'Ir para o grupo',
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 0151c4b..ad8bf53 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
@@ -428,6 +428,7 @@ export default {
'presence.online': '有一个服务本群组的 node 在线',
'presence.offline': '本群组没有可达的 node',
'presence.unknown': '尚未检测',
+ 'presence.indexing': '正在索引 — {pct}%',
'chat.load_older': '加载更早的 {n} 条消息',
'chat.start_of_history': '对话开头',
'chat.today': '今天',
@@ -543,6 +544,13 @@ export default {
// Settings — node
'settings_node.detach_failed_continue': '无法从 node 上分离此群组。是否仍然在 hub 上删除?',
'settings_node.roots': '共享目录',
+ 'settings_node.scan_title': '扫描',
+ 'settings_node.scan_hint': 'node 多久重新检查一次目录以发现可能错过的变化,以及文件变化后等待多久才建立索引。',
+ 'settings_node.scan_reconcile_label': '重新检查间隔(分钟)',
+ 'settings_node.scan_debounce_label': '变化后的等待时间(秒)',
+ 'settings_node.scan_save': '保存',
+ 'settings_node.scan_saving': '保存中…',
+ 'settings_node.scan_saved': '已保存。',
// Create group wizard
'wizard.title': '创建群组',
@@ -566,6 +574,9 @@ export default {
'wizard.step_add_roots': '添加目录',
'wizard.step_gek': '初始化加密密钥',
'wizard.step_pair': '设置配对',
+ 'wizard.step_index': '正在索引文件',
+ 'wizard.indexing_progress': '正在索引… {pct}%',
+ 'wizard.indexing_current_dir': '正在扫描:{dir}',
'wizard.done_title': '群组已创建',
'wizard.done_message': '您的群组已就绪。您的 node 正在托管它,加密已设置完成。',
'wizard.go_to_group': '前往群组',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 30df80e..a5312e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -266,6 +266,68 @@ export const node = {
};
/**
+ * Poll a group's initial-scan progress on the local node (loopback), until
+ * it reports it is no longer scanning. For "add a directory" in Settings,
+ * where the group is already hosted — index-status is meaningful the whole
+ * time. NOT for a brand-new group the wizard just attached: see
+ * waitForGroupHosted below for why that needs a different exit condition.
+ *
+ * `onUpdate` is called with each {scanning, scanned_bytes, total_bytes,
+ * current_dir} snapshot, including the final one where scanning is false.
+ */
+export async function watchIndexProgress(groupId, onUpdate, { intervalMs = 500 } = {}) {
+ for (;;) {
+ let status;
+ try {
+ status = await node.call('GET', `/api/groups/${groupId}/index-status`);
+ } catch {
+ // The node went away mid-poll — stop rather than spin forever; the
+ // caller's own connection-status handling already covers that case.
+ return;
+ }
+ onUpdate(status);
+ if (!status.scanning) return;
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
+ }
+}
+
+/**
+ * Create Group wizard only: wait for a brand-new group to actually become
+ * usable on the node, showing index-status along the way.
+ *
+ * Not the same wait as watchIndexProgress above. `/api/reload` returns as
+ * soon as the reload is scheduled (ops.start_reload) — before the node has
+ * even created an indexer for the group, let alone started scanning. A
+ * naive "poll index-status until scanning is false" would see the default
+ * idle answer on that very first poll and return immediately, and every
+ * group-scoped call after it (add a root, init the GEK) would still 404
+ * with "not configured"/"not hosted" for as long as the real scan actually
+ * takes. The only answer that means "safe to proceed" is the group
+ * genuinely appearing in /api/groups (groups_ctx, daemon.py) — index-status
+ * is read purely for the progress bar.
+ */
+export async function waitForGroupHosted(groupId, onProgress,
+ { intervalMs = 500, timeoutMs = 30 * 60 * 1000 } = {}) {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ try {
+ const status = await node.call('GET', `/api/groups/${groupId}/index-status`);
+ if (onProgress) onProgress(status);
+ } catch { /* keep waiting — the loopback API can be momentarily busy */ }
+
+ try {
+ const list = await node.call('GET', '/api/groups');
+ if (Array.isArray(list.groups) && list.groups.some((g) => g.id === groupId)) return;
+ } catch { /* keep waiting */ }
+
+ if (Date.now() > deadline) {
+ throw new Error('The node did not finish attaching this group in time');
+ }
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+}
+
+/**
* LAN cast relay — re-serve decrypted video segments over HTTP so a
* Chromecast or Smart TV on the same Wi-Fi can play the stream.
*
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 20b9f8c..2cb27d0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -989,6 +989,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-size: 0.9em;
color: var(--text);
}
+.settings-label input[type="number"] {
+ display: block;
+ width: 90px;
+ margin-top: 4px;
+ padding: 6px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.95em;
+}
.settings-value {
font-size: 0.9em;
@@ -1887,6 +1898,19 @@ a.transfer-name {
.presence-online { background: var(--success); }
.presence-offline { background: var(--error); }
.presence-unknown { background: transparent; border-color: var(--text-dim); }
+/* The node is scanning this group's files right now (initial import, or a
+ directory just added) — a state, not a verdict, so it pulses rather than
+ sitting on a fixed color; green at rest, green again once scanning stops
+ (driven by IndexProgress.scanning, guaranteed to turn back off — see
+ daemon.py _progress_pusher and webrtc_server.py's handshake ack). */
+.presence-indexing {
+ background: var(--success);
+ animation: presence-indexing-pulse 1.6s ease-in-out infinite;
+}
+@keyframes presence-indexing-pulse {
+ 0%, 100% { background: var(--success); }
+ 50% { background: #f59e0b; }
+}
.sidebar-item-name {
overflow: hidden;
@@ -2284,7 +2308,7 @@ a.transfer-name {
align-items: center;
justify-content: space-between;
padding: 8px 12px;
- background: var(--surface);
+ background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
gap: 8px;
@@ -2322,7 +2346,7 @@ a.transfer-name {
align-items: center;
gap: 10px;
padding: 10px 14px;
- background: var(--surface);
+ background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9em;
@@ -2337,6 +2361,37 @@ a.transfer-name {
.wizard-step-error .wizard-step-icon { color: var(--error); }
.wizard-step-pending { opacity: 0.5; }
+/* ── Indexing progress (Create Group wizard, and "add a directory" in
+ Settings) — bytes-based, not file-count-based; see the two call sites of
+ platform.watchIndexProgress. ── */
+.index-progress {
+ padding: 10px 14px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 0.9em;
+}
+.index-progress-bar {
+ height: 6px;
+ border-radius: 3px;
+ background: var(--border);
+ overflow: hidden;
+ margin-bottom: 8px;
+}
+.index-progress-fill {
+ height: 100%;
+ background: var(--accent);
+ transition: width 0.3s ease;
+}
+.index-progress-label { font-weight: 600; }
+.index-progress-dir {
+ color: var(--text-dim);
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.warning-msg {
padding: 10px 14px;
background: color-mix(in srgb, #f59e0b 15%, transparent);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index da98253..4f6b656 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -103,8 +103,10 @@ class MeshBayTransport {
set onStreamEnd(fn) { this._onStreamEnd = fn; }
set onStreamError(fn) { this._onStreamError = fn; }
set onIndexSync(fn) { this._onIndexSync = fn; }
+ set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
+ set onIndexProgress(fn) { this._onIndexProgress = fn; }
get sessionKeys() { return this._sessionKeys; }
@@ -634,6 +636,29 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * How often the node's reconciliation backstop runs, and how long it
+ * waits after a file's last write before hashing it (indexer.py
+ * DirectoryIndexer). Whole seconds only: the node builds the signing
+ * subject with Python's `%g` (drops a trailing ".0"), and the simplest
+ * way to always match it byte-for-byte from JS is to never send a
+ * fractional value in the first place.
+ */
+ async setScanSettings(reconcileIntervalSecs, debounceSecs, signFn) {
+ const reconcile = Math.round(reconcileIntervalSecs);
+ const debounce = Math.round(debounceSecs);
+ const msg = await this._sendAndWait({
+ type: 'set_scan_settings', v: '0.1',
+ reconcile_interval_secs: reconcile, debounce_secs: debounce,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'set_scan_settings', `${reconcile},${debounce}`, signFn);
+ }
+ return msg;
+ }
+
async revokeMember(userId, signFn) {
const msg = await this._sendAndWait({
type: 'member_revoke', v: '0.1', user_id: userId,
@@ -1251,6 +1276,37 @@ class MeshBayTransport {
this._onAppsEnabled(msg.apps || []);
}
+ // The operator's node is scanning — never the entries themselves, just
+ // enough to animate a presence dot. Pushed periodically while it runs,
+ // plus once more on the transition back to idle (daemon.py
+ // _progress_pusher). UNLIKE member_upload_ack/apps_enabled_ack above,
+ // this is never a reply to anything this browser asked for — nobody
+ // calls _sendAndWait for it — so it MUST return here. Falling through
+ // to the "oldest pending" guess below hands it to whatever unrelated
+ // request happens to be waiting (a handshake, a chat history fetch),
+ // which then waits forever for its real answer while this one already
+ // "arrived" — and every message after that is one slot off too. Found
+ // live: a group mid-scan corrupted its own handshake and chat history
+ // this way, arriving roughly every 2s for as long as scanning ran.
+ if (msg.type === 'index_progress') {
+ if (this._onIndexProgress) {
+ this._onIndexProgress({
+ scanning: Boolean(msg.scanning),
+ scanned_bytes: msg.scanned_bytes || 0,
+ total_bytes: msg.total_bytes || 0,
+ });
+ }
+ return;
+ }
+
+ // Same reasoning as index_progress: nobody awaits this one either, it
+ // is purely informational (group-settings.js does not currently act on
+ // it), so it must not be left to fall through to the oldest pending
+ // request.
+ if (msg.type === 'set_scan_settings_ack') {
+ return;
+ }
+
if (msg.type === 'index_sync' && msg.entries) {
if (this._onIndexSync) this._onIndexSync(msg);
const oldest = this._pending.entries().next();
@@ -1260,6 +1316,16 @@ class MeshBayTransport {
return;
}
+ // Incremental update — additions/deletions only, never the whole index.
+ // Only ever arrives after the full index this browser already has (the
+ // node's first push to a newly connected peer is always index_sync, see
+ // daemon.py _broadcast_index_change), so there is always a base to
+ // apply it to.
+ if (msg.type === 'index_delta') {
+ if (this._onIndexDelta) this._onIndexDelta(msg);
+ return;
+ }
+
if (msg.type === 'file_chunk') {
const key = `chunk:${msg.file_id}:${msg.chunk_index}`;
for (const [, handler] of this._pending) {
@@ -1298,6 +1364,29 @@ class MeshBayTransport {
return;
}
+ // chat_hist_resp answers a `chat_hist` request, but under a different
+ // type string — unlike index_sync, which is asked for and answered under
+ // the same name, so the generic fallback below happens to work for it by
+ // accident. Without this check, whenever a chat_hist_resp arrives while
+ // something else this browser asked for (fetchIndex, even the handshake
+ // itself) is still the oldest pending entry, it gets handed to that
+ // instead: the request chat_hist_resp actually belongs to then hangs
+ // until _sendAndWait's own 30s timeout, and whatever it stole from
+ // resolves with the wrong shape entirely — reproduced live as a
+ // consistent ~30s hang immediately after a successful handshake, for one
+ // specific group and not others connected the same way, which is exactly
+ // what depending on response arrival order rather than on request type
+ // predicts: it fires only when the two responses happen to reorder.
+ if (msg.type === 'chat_hist_resp') {
+ const oldest = this._pending.entries().next();
+ if (!oldest.done && oldest.value[1]._reqType === 'chat_hist') {
+ oldest.value[1].resolve(msg);
+ } else {
+ console.warn('[MeshBay] chat_hist_resp with no matching chat_hist pending');
+ }
+ return;
+ }
+
// Everything above is routed by something in the message. What is left is
// matched by arrival order, which is only ever a guess — and a wrong guess
// here hands one request's answer to another, which then waits for a reply
@@ -1349,6 +1438,16 @@ function _encodeValue(val, parts) {
const b = new Uint8Array(5); b[0] = 0xce;
new DataView(b.buffer).setUint32(1, val, false);
parts.push(b);
+ } else if (val >= 0 && val <= Number.MAX_SAFE_INTEGER) {
+ // Same split as the 0xcf decoder case above, in reverse — without
+ // this, a value over 0xffffffff fell to the plain int32 branch
+ // below and silently wrapped to a wrong, unrelated number instead
+ // of failing loudly.
+ const b = new Uint8Array(9); b[0] = 0xcf;
+ const dv = new DataView(b.buffer);
+ dv.setUint32(1, Math.floor(val / 4294967296), false);
+ dv.setUint32(5, val % 4294967296, false);
+ parts.push(b);
} else if (val >= -32 && val < 0) {
parts.push(new Uint8Array([val & 0xff]));
} else if (val >= -128 && val < 0) {
@@ -1460,6 +1559,26 @@ function _decodeValue(buf, view, offset) {
case 0xcc: return [buf[offset + 1], offset + 2];
case 0xcd: return [view.getUint16(offset + 1, false), offset + 3];
case 0xce: return [view.getUint32(offset + 1, false), offset + 5];
+ // uint64/int64 — never emitted by this file's own encoder (a JS number
+ // above 0xffffffff falls to float64 there), but the node's real msgpack
+ // library sends a plain uint64 for any Python int over ~4.3 billion, and
+ // a raw byte count crosses that easily (found live: IndexProgress.
+ // scanned_bytes/total_bytes in the handshake ack, indexer.py, for a
+ // group whose total library size exceeds ~4 GB). Split into two 32-bit
+ // halves rather than DataView's getBigUint64/getBigInt64 — a BigInt
+ // would silently poison every arithmetic use of these fields elsewhere
+ // (percentage math, comparisons) — and every real byte count fits in a
+ // plain JS number well under Number.MAX_SAFE_INTEGER (2^53).
+ case 0xcf: {
+ const hi = view.getUint32(offset + 1, false);
+ const lo = view.getUint32(offset + 5, false);
+ return [hi * 4294967296 + lo, offset + 9];
+ }
+ case 0xd3: {
+ const hi = view.getInt32(offset + 1, false);
+ const lo = view.getUint32(offset + 5, false);
+ return [hi * 4294967296 + lo, offset + 9];
+ }
case 0xcb: return [view.getFloat64(offset + 1, false), offset + 9];
case 0xd0: return [view.getInt8(offset + 1), offset + 2];
case 0xd1: return [view.getInt16(offset + 1, false), offset + 3];