Files: c4c384106bcf50acf4c0fcb3f7b842ad68f25bcb / app / page / network.js
10555 bytesRaw
1 | const nest = require('depnest') |
2 | const { h, Value, Dict, dictToCollection, onceTrue, computed, watch, watchAll, throttle, resolve } = require('mutant') |
3 | const Chart = require('chart.js') |
4 | const pull = require('pull-stream') |
5 | const { isInvite } = require('ssb-ref') |
6 | |
7 | const MINUTE = 60 * 1000 |
8 | const DAY = 24 * 60 * MINUTE |
9 | |
10 | const GRAPH_Y_STEP = 50 |
11 | const GRAPH_Y_MIN = 100 |
12 | |
13 | exports.gives = nest({ |
14 | 'app.html.menuItem': true, |
15 | 'app.page.network': true |
16 | }) |
17 | |
18 | exports.needs = nest({ |
19 | 'about.html.avatar': 'first', |
20 | 'app.html.scroller': 'first', |
21 | 'app.sync.goTo': 'first', |
22 | 'sbot.obs.connection': 'first', |
23 | 'sbot.obs.localPeers': 'first', |
24 | 'sbot.obs.connectedPeers': 'first' |
25 | }) |
26 | |
27 | exports.create = function (api) { |
28 | return nest({ |
29 | 'app.html.menuItem': menuItem, |
30 | 'app.page.network': networkPage |
31 | }) |
32 | |
33 | function menuItem () { |
34 | return h('a', { |
35 | 'ev-click': () => api.app.sync.goTo({ page: 'network' }) |
36 | }, '/network') |
37 | } |
38 | |
39 | function networkPage (location) { |
40 | const minsPerStep = 10 |
41 | const scale = 1 * DAY |
42 | |
43 | const state = buildState({ api, minsPerStep, scale }) |
44 | const canvas = h('canvas', { height: 500, width: 1200, style: { height: '500px', width: '1200px' } }) |
45 | |
46 | const inputInvite = ev => { |
47 | state.inviteResult.set(null) |
48 | const invite = ev.target.value.replace(/^\s*"?/, '').replace(/"?\s*$/, '') |
49 | if (!isInvite(invite)) { |
50 | state.invite.set() |
51 | return |
52 | } |
53 | |
54 | ev.target.value = invite |
55 | state.invite.set(invite) |
56 | } |
57 | const useInvite = () => { |
58 | state.inviteProcessing.set(true) |
59 | |
60 | onceTrue(api.sbot.obs.connection, server => { |
61 | server.invite.accept(resolve(state.invite), (err, data) => { |
62 | state.inviteProcessing.set(false) |
63 | state.invite.set() |
64 | |
65 | if (err) { |
66 | state.inviteResult.set(false) |
67 | console.error(err) |
68 | return |
69 | } |
70 | state.inviteResult.set(true) |
71 | console.log(data) |
72 | }) |
73 | }) |
74 | } |
75 | |
76 | const page = h('NetworkPage', [ |
77 | h('div.container', [ |
78 | h('h1', 'Network'), |
79 | h('section', [ |
80 | h('h2', [ |
81 | 'Local Peers', |
82 | h('i.fa.fa-question-circle-o', { title: 'these are people on the same WiFi/ LAN as you right now. You might not know some of them yet, but you can click through to find out more about them and follow them if you like.' }) |
83 | ]), |
84 | computed(state.localPeers, peers => { |
85 | if (!peers.length) return h('p', 'No local peers (on same wifi/ LAN)') |
86 | |
87 | return peers.map(peer => api.about.html.avatar(peer)) |
88 | }) |
89 | ]), |
90 | h('section', [ |
91 | h('h2', [ |
92 | 'Remote Peers', |
93 | h('i.fa.fa-question-circle-o', { title: 'these are peers which have fixed addresses, and are likely friends of friends (a.k.a. pubs)' }) |
94 | ]), |
95 | computed(state.remotePeers, peers => { |
96 | if (!peers.length) return h('p', 'No remote peers connected') |
97 | |
98 | return peers.map(peer => api.about.html.avatar(peer)) |
99 | }), |
100 | h('div.invite', [ |
101 | h('input', { |
102 | 'placeholder': 'invite code for a remote peer (pub)', |
103 | 'ev-input': inputInvite |
104 | }), |
105 | computed([state.invite, state.inviteProcessing], (invite, processing) => { |
106 | if (processing) return h('i.fa.fa-spinner.fa-pulse') |
107 | if (invite) return h('button -primary', { 'ev-click': useInvite }, 'use invite') |
108 | |
109 | return h('button', { disabled: 'disabled', title: 'not a valid invite code' }, 'use invite') |
110 | }), |
111 | computed(state.inviteResult, result => { |
112 | if (result === null) return |
113 | |
114 | return result |
115 | ? h('i.fa.fa-check') |
116 | : h('i.fa.fa-times') |
117 | }) |
118 | ]) |
119 | ]), |
120 | h('section', [ |
121 | h('h2', 'My state'), |
122 | // mix: hello friend, this area is a total Work In Progress. It's a mess but useful diagnostics. |
123 | // Let's redesign and revisit it aye! |
124 | h('div', ['My sequence: ', state.seq]), |
125 | h('div', [ |
126 | 'Replicated:', |
127 | h('div', computed([state.seq, dictToCollection(state.replication)], (seq, replication) => { |
128 | return replication.map(r => { |
129 | if (!r.value.replicating) { |
130 | return h('div', [ |
131 | h('code', r.key), |
132 | ' no ebt data' |
133 | ]) |
134 | } |
135 | |
136 | const { requested, sent } = r.value.replicating |
137 | // TODO report that r.value.seq is NOT the current local value of the seq (well it's ok, just just gets out of sync) |
138 | // const reqDiff = requested - r.value.seq |
139 | const reqDiff = requested - seq |
140 | const sentDiff = sent - seq |
141 | |
142 | return h('div', [ |
143 | h('code', r.key), |
144 | ` - requested: ${requested} `, |
145 | reqDiff === 0 ? h('i.fa.fa-check-circle-o') : `(${reqDiff})`, |
146 | `, sent: ${sent} `, |
147 | sentDiff === 0 ? h('i.fa.fa-check-circle-o') : `(${sentDiff})` |
148 | ]) |
149 | }) |
150 | })) |
151 | ]) |
152 | ]), |
153 | h('section', [ |
154 | h('h2', [ |
155 | 'Received Messages', |
156 | h('i.fa.fa-question-circle-o', { |
157 | title: `Messages received per ${minsPerStep}-minute block over the last ${scale / DAY} days` |
158 | }) |
159 | ]), |
160 | canvas |
161 | ]) |
162 | ]) |
163 | ]) |
164 | |
165 | initialiseChart({ canvas, state }) |
166 | |
167 | var { container } = api.app.html.scroller({ prepend: page }) |
168 | container.title = '/network' |
169 | return container |
170 | } |
171 | } |
172 | |
173 | function initialiseChart ({ canvas, state: { data, range } }) { |
174 | var chart = new Chart(canvas.getContext('2d'), chartConfig(range)) |
175 | |
176 | watch(range, ({ lower, upper }) => { |
177 | // set horizontal scale |
178 | chart.options.scales.xAxes[0].time.min = lower |
179 | chart.options.scales.xAxes[0].time.max = upper |
180 | chart.update() |
181 | }) |
182 | |
183 | watchAll([throttle(data, 300), range], (data, { lower, upper }) => { |
184 | const _data = Object.keys(data) |
185 | .sort((a, b) => a < b ? -1 : +1) |
186 | .map(ts => { |
187 | return { |
188 | t: Number(ts), // NOTE - might need to offset by a half-step ? |
189 | y: data[ts] |
190 | } |
191 | }) |
192 | |
193 | // update chard data |
194 | chart.data.datasets[0].data = _data |
195 | |
196 | // scales the height of the graph (to the visible data)! |
197 | const slice = _data |
198 | .filter(d => d.t >= lower && d.t < upper) |
199 | .map(d => d.y) |
200 | .sort((a, b) => a > b ? -1 : +1) |
201 | |
202 | var h = slice[0] |
203 | if (!h || h < GRAPH_Y_MIN) h = GRAPH_Y_MIN // min-height |
204 | else h = h + (GRAPH_Y_STEP - h % GRAPH_Y_STEP) // round height to multiples of GRAPH_Y_STEP |
205 | chart.options.scales.yAxes[0].ticks.max = h |
206 | |
207 | chart.update() |
208 | }) |
209 | } |
210 | |
211 | // ///// HELPERS ///// |
212 | |
213 | function buildState ({ api, minsPerStep, scale }) { |
214 | // build localPeers, remotePeers |
215 | const localPeers = throttle(api.sbot.obs.localPeers(), 1000) |
216 | const remotePeers = computed([localPeers, throttle(api.sbot.obs.connectedPeers(), 1000)], (local, connected) => { |
217 | return connected.filter(peer => !local.includes(peer)) |
218 | }) |
219 | |
220 | // build seq, replication (my current state, and replicated state) |
221 | const seq = Value() |
222 | const replication = Dict({}) |
223 | onceTrue(api.sbot.obs.connection, server => { |
224 | setInterval(() => { |
225 | // TODO check ebt docs if this is best method |
226 | server.ebt.peerStatus(server.id, (err, data) => { |
227 | if (err) return console.error(err) |
228 | |
229 | seq.set(data.seq) |
230 | for (var peer in data.peers) { |
231 | replication.put(peer, data.peers[peer]) |
232 | } |
233 | }) |
234 | }, 5e3) |
235 | }) |
236 | |
237 | // build data, range |
238 | const data = Dict({ |
239 | [toTimeBlock(Date.now(), minsPerStep) + minsPerStep * MINUTE]: 0, |
240 | [toTimeBlock(Date.now(), minsPerStep) + minsPerStep * MINUTE - scale]: 0 |
241 | }) |
242 | onceTrue(api.sbot.obs.connection, server => { |
243 | getData({ data, server, minsPerStep, scale }) |
244 | }) |
245 | |
246 | const latest = Value(toTimeBlock(Date.now(), minsPerStep)) |
247 | // start of the most recent bar |
248 | setInterval(() => { |
249 | latest.set(toTimeBlock(Date.now(), minsPerStep)) |
250 | }, minsPerStep / 4 * MINUTE) |
251 | |
252 | const range = computed([latest], (latest) => { |
253 | return { |
254 | upper: latest + minsPerStep * MINUTE, |
255 | lower: latest + minsPerStep * MINUTE - scale |
256 | } |
257 | }) |
258 | return { |
259 | localPeers, |
260 | remotePeers, |
261 | seq, |
262 | replication, |
263 | data, // TODO rename this !! |
264 | range, |
265 | invite: Value(), |
266 | inviteProcessing: Value(false), |
267 | inviteResult: Value(null) |
268 | } |
269 | } |
270 | |
271 | function getData ({ data, server, minsPerStep, scale }) { |
272 | const upperEnd = toTimeBlock(Date.now(), minsPerStep) + minsPerStep * MINUTE |
273 | const lowerBound = upperEnd - scale |
274 | |
275 | const query = [ |
276 | { |
277 | $filter: { |
278 | timestamp: { $gte: lowerBound } |
279 | } |
280 | }, { |
281 | $filter: { |
282 | value: { |
283 | author: { $ne: server.id } |
284 | } |
285 | } |
286 | }, { |
287 | $map: { |
288 | ts: ['timestamp'] |
289 | } |
290 | } |
291 | ] |
292 | |
293 | pull( |
294 | server.query.read({ query, live: true }), |
295 | pull.filter(m => !m.sync), |
296 | pull.map(m => toTimeBlock(m.ts, minsPerStep)), |
297 | pull.drain(ts => { |
298 | if (data.has(ts)) data.put(ts, data.get(ts) + 1) |
299 | else data.put(ts, 1) |
300 | }) |
301 | ) |
302 | } |
303 | |
304 | function toTimeBlock (ts, minsPerStep) { |
305 | return Math.floor(ts / (minsPerStep * MINUTE)) * (minsPerStep * MINUTE) |
306 | } |
307 | |
308 | function chartConfig ({ lower, upper }) { |
309 | const barColor = 'hsla(215, 57%, 60%, 1)' |
310 | |
311 | return { |
312 | type: 'bar', |
313 | data: { |
314 | datasets: [{ |
315 | backgroundColor: barColor, |
316 | borderColor: barColor, |
317 | data: [] |
318 | }] |
319 | }, |
320 | options: { |
321 | legend: { |
322 | display: false |
323 | }, |
324 | scales: { |
325 | xAxes: [{ |
326 | type: 'time', |
327 | distribution: 'linear', |
328 | time: { |
329 | // unit: 'day', |
330 | // min: lower, |
331 | // max: upper, |
332 | // tooltipFormat: 'MMMM D', |
333 | // stepSize: 7 |
334 | unit: 'minute', |
335 | min: lower, |
336 | max: upper, |
337 | tooltipFormat: 'HH:mm', |
338 | stepSize: 4 * 60 |
339 | // stepSize: 240 |
340 | }, |
341 | bounds: 'ticks', |
342 | ticks: { |
343 | // maxTicksLimit: 4 // already disabled |
344 | }, |
345 | gridLines: { |
346 | display: false |
347 | } |
348 | // maxBarThickness: 2 |
349 | }], |
350 | |
351 | yAxes: [{ |
352 | ticks: { |
353 | min: 0, |
354 | stepSize: GRAPH_Y_STEP, |
355 | suggestedMax: GRAPH_Y_MIN |
356 | } |
357 | }] |
358 | }, |
359 | animation: { |
360 | // duration: 300 |
361 | } |
362 | } |
363 | } |
364 | } |
365 |
Built with git-ssb-web