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
|
/**
* What the indexing dock shows, worked out from what nodes report. No DOM and no
* imports, so tests/test_indexing_dock.py runs this file under node as it is.
*
* Two sources describe a group's indexing:
*
* - The loopback API (`GET /api/index-status`, the node's ui/app.py) answers on
* the node's own machine, for every group it hosts, whatever page is open,
* and names the roots.
* - MNP `index_progress` pushes reach an operator anywhere, but only while that
* group's page holds a connection, and carry counters only (decision D3): the
* root under way is a position in the roots table the page already opened,
* and the roots waiting are a count.
*
* Where both describe a group, the loopback answer wins. It is the fuller one.
*/
// A reconcile pass or a burst of watchdog events is usually a file or two, done
// in less time than it takes to read a bar. Shown only past either threshold;
// a root walked for the first time, or again, is always shown.
export const NOISE_BYTES = 1024 ** 3;
export const NOISE_MS = 5000;
// How long "indexing finished" stays on screen.
export const DONE_MS = 4000;
export function fromLoopback(g) {
return {
groupId: g.group_id,
groupName: g.group_name || '',
scanning: Boolean(g.scanning),
kind: g.kind || '',
root: g.root || '',
scannedBytes: g.scanned_bytes || 0,
totalBytes: g.total_bytes || 0,
filesDone: g.files_done || 0,
filesTotal: g.files_total || 0,
queued: Array.isArray(g.queued) ? g.queued : [],
// What the disk has given no answer on, and for how long. A path, so the
// loopback answer only: a push carries counters and nothing else.
slowPath: g.slow_path || '',
slowSecs: g.slow_secs || 0,
};
}
/** The last segment of a path, whichever way its separators lean. */
export function baseName(path) {
return String(path || '').split(/[\\/]/).filter(Boolean).pop() || '';
}
export function fromPush(groupId, s, roots) {
const pos = Number.isInteger(s.root_pos) ? s.root_pos : -1;
const known = pos >= 0 && Array.isArray(roots) && roots[pos];
return {
groupId,
groupName: '',
scanning: Boolean(s.scanning),
kind: s.kind || '',
root: known ? (roots[pos].name || '') : '',
scannedBytes: s.scanned_bytes || 0,
totalBytes: s.total_bytes || 0,
filesDone: s.files_done || 0,
filesTotal: s.files_total || 0,
queued: Number.isInteger(s.queued) ? s.queued : 0,
slowPath: '',
slowSecs: 0,
};
}
export function queuedCount(job) {
return Array.isArray(job.queued) ? job.queued.length : (job.queued || 0);
}
export function percent(job) {
return job.totalBytes
? Math.min(100, Math.round(100 * job.scannedBytes / job.totalBytes)) : 0;
}
/** One job per group, loopback first, named after the hub's group list. */
export function mergeActivity(local, pushed, groups) {
const names = new Map((groups || []).map((g) => [g.id, g.name]));
const ids = [...new Set([...Object.keys(local), ...Object.keys(pushed)])];
return ids.map((gid) => {
const job = local[gid] || pushed[gid];
return { ...job, groupName: names.get(gid) || job.groupName || gid.slice(0, 8) };
});
}
function busy(job) {
return job.scanning || queuedCount(job) > 0;
}
function loud(job, since, now) {
// '' while scanning is a node older than `kind`: shown, as it always was.
return job.kind === 'scan' || job.kind === 'rescan' || job.kind === ''
|| queuedCount(job) > 0
|| job.totalBytes >= NOISE_BYTES
|| now - since >= NOISE_MS;
}
/**
* The rows to draw, and what to remember for the next call.
*
* `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, 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 = {};
const rows = [];
for (const job of jobs) {
const gid = job.groupId;
const prev = memory[gid] || {};
if (busy(job)) {
const sig = `${job.kind}|${job.root}`;
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);
// 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) {
next[gid] = prev;
rows.push({ ...job, state: 'done' });
}
}
return { rows, memory: next };
}
/** Hidden until that group has nothing left to index. */
export function hideRow(memory, groupId) {
const prev = 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 } };
}
|