aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/index-dock.js
blob: b136578ba55bf5c809bf69da2906164e7f5c3055 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import { html, useState, useEffect, useRef } from './vendor/htm-preact.js';
import { t, getLocale } from './i18n.js';
import * as platform from './platform.js';
import { formatSize } from './file-utils.js';
import { navigate, session } from './hub-client.js';
import { useStickyBand } from './sticky.js';
import { Icon } from './icon.js';
import {
  fromLoopback, fromPush, mergeActivity, dockRows, hideRow, percent, queuedCount,
} from './index-dock-model.js';

// ── Indexing dock ───────────────────────────────────────────────────────────
//
// A node asked to index a large directory works for hours, and the only sign of
// it used to be a progress bar in the one Settings panel that had started it —
// gone the moment the operator went anywhere else, and never shown at all when
// the directory was added from another machine. This band sits above the music
// bar on every page, one row per group with indexing under way; the roots of a
// group are walked one after another, so a group's row names the one being
// walked and the ones waiting (index-dock-model.js).

// Loopback is cheap and local; a node with nothing to do is asked less often,
// and one that is not there at all, rarely.
const BUSY_POLL_MS = 2000;
const IDLE_POLL_MS = 5000;
const ABSENT_POLL_MS = 30000;

// MNP pushes, reported by the group page holding the connection. Module state
// rather than a prop through App: the page and the dock are far apart, and a
// push every two seconds must not re-render the whole application.
const pushed = new Map();
const listeners = new Set();

/**
 * Called by group-page.js for an operator's connection only — an ordinary
 * member keeps the sidebar dot. `status` is the push (or the handshake ack's
 * `indexing`); `roots` the roots table the page opened, which names `root_pos`.
 * `null` when the page lets go of the group: nothing will update the entry any
 * more, and a stale "scanning" would stay on screen for ever.
 */
export function reportIndexPush(groupId, status, roots) {
  if (status) pushed.set(groupId, fromPush(groupId, status, roots));
  else pushed.delete(groupId);
  for (const fn of listeners) fn();
}

function useLoopbackActivity() {
  const [local, setLocal] = useState({});
  useEffect(() => {
    if (!platform.node.available) return undefined;
    let stopped = false;
    let timer = null;
    let last = '';
    const poll = async () => {
      let delay = IDLE_POLL_MS;
      try {
        const data = await platform.node.call('GET', '/api/index-status');
        const next = {};
        for (const g of (data && data.groups) || []) next[g.group_id] = fromLoopback(g);
        if (Object.values(next).some((j) => j.scanning || j.queued.length)) {
          delay = BUSY_POLL_MS;
        }
        const text = JSON.stringify(next);
        if (!stopped && text !== last) { last = text; setLocal(next); }
      } catch {
        // No node on this machine, or one older than the route.
        if (!stopped && last !== '{}') { last = '{}'; setLocal({}); }
        delay = ABSENT_POLL_MS;
      }
      if (!stopped) timer = setTimeout(poll, delay);
    };
    poll();
    return () => { stopped = true; clearTimeout(timer); };
  }, []);
  return local;
}

function usePushedActivity() {
  const [snapshot, setSnapshot] = useState(() => Object.fromEntries(pushed));
  useEffect(() => {
    const update = () => setSnapshot(Object.fromEntries(pushed));
    listeners.add(update);
    update();
    return () => { listeners.delete(update); };
  }, []);
  return snapshot;
}

function DockRow({ row, onOpen, onHide }) {
  const done = row.state === 'done';
  const waiting = !done && !row.scanning;
  const listing = !done && row.scanning && !row.totalBytes;
  const pct = percent(row);
  const number = (v) => v.toLocaleString(getLocale());

  const status = done ? t('indexdock.done')
    : waiting ? t('indexdock.waiting')
    : t(`indexdock.kind_${row.kind || 'scan'}`);
  const where = row.root ? `${row.groupName} › ${row.root}` : row.groupName;

  let meta = '';
  if (listing) {
    meta = t('indexdock.listing');
  } else if (!done && !waiting) {
    meta = [
      t('indexdock.percent', { pct }),
      `${formatSize(row.scannedBytes)} / ${formatSize(row.totalBytes)}`,
      row.filesTotal
        ? t('indexdock.files', { done: number(row.filesDone), total: number(row.filesTotal) })
        : '',
    ].filter(Boolean).join(' · ');
  }

  const count = queuedCount(row);
  const next = done || !count ? ''
    : Array.isArray(row.queued) ? t('indexdock.next', { roots: row.queued.join(', ') })
    : t('indexdock.next_count', { count: number(count) });

  const moving = listing || waiting;
  return html`
    <div class="index-dock-row ${done ? 'index-dock-done' : ''}">
      <button type="button" class="index-dock-main" title=${t('indexdock.open')}
        onClick=${onOpen}>
        <span class="index-dock-head">
          <span class="index-dock-title"><strong>${status}</strong> ${where}</span>
          ${meta && html`<span class="index-dock-meta">${meta}</span>`}
        </span>
        <span class="index-progress-bar index-dock-bar">
          <span class="index-progress-fill ${moving ? 'index-dock-indeterminate' : ''}"
            style="width:${done ? 100 : moving ? 30 : pct}%"></span>
        </span>
        ${next && html`<span class="index-dock-next">${next}</span>`}
      </button>
      ${!done && html`
        <button type="button" class="index-dock-hide" title=${t('indexdock.hide')}
          aria-label=${t('indexdock.hide')} onClick=${onHide}>
          <${Icon} name="close" />
        </button>
      `}
    </div>
  `;
}

export function IndexingDock({ groups }) {
  const local = useLoopbackActivity();
  const remote = usePushedActivity();
  const memory = useRef({});
  const [, setTick] = useState(0);
  // Publishes `--index-dock-h` for the sidebar, as the music bar publishes its
  // own; withdrawn when the dock renders nothing.
  const band = useStickyBand('--index-dock-h');

  const jobs = mergeActivity(local, remote, groups);
  const { rows, memory: nextMemory } = dockRows(jobs, memory.current, Date.now());
  memory.current = nextMemory;

  // Time alone changes what is shown — a burst crossing five seconds, a
  // "finished" row expiring — so while anything is on or pending, look again.
  const pending = Object.values(nextMemory).length > 0;
  useEffect(() => {
    if (!pending) return undefined;
    const id = setInterval(() => setTick((n) => n + 1), 1000);
    return () => clearInterval(id);
  }, [pending]);

  if (!rows.length) return null;
  return html`
    <div class="index-dock" ref=${band}>
      ${rows.map((row) => html`
        <${DockRow} key=${row.groupId} row=${row}
          onOpen=${() => {
            session.openGroupTab = { groupId: row.groupId, tab: 'settings' };
            navigate('/group/' + row.groupId);
          }}
          onHide=${() => {
            memory.current = hideRow(memory.current, row.groupId);
            setTick((n) => n + 1);
          }} />
      `)}
    </div>
  `;
}