aboutsummaryrefslogtreecommitdiffstats
path: root/web/js/nms-data.js
blob: 6a00fba90e9768c687bdd10ee19c7aa0ddeeef9c (plain)
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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"use strict";

/*
 * This file/module/whatever is an attempt to gather all data collection in
 * one place.
 *
 * The basic idea is to have all periodic data updates unified here, with
 * stats, tracking of "ajax overflows" and general-purpose error handling
 * and callbacks and whatnot, instead of all the custom stuff that we
 * started out with.
 *
 * Sources are identified by a name, which is then available in
 * nmsData[name] in full. A copy of the previous data set is kept in
 * nmsData.old[name]. You can use getNow / setNow() to append a 'now='
 * string.
 *
 * nmsData[name] - actual data
 * nmsData.old[name] - previous copy of data
 * nmsData.registerSource() - add a source, will be polled periodicall
 * nmsData.addHandler()
 * nmsData.updateSource() - issue a one-off update, outside of whatever
 * 			    periodic polling might take place
 * nmsData.invalidate() - Invalidate browser-cache.
 */

var nmsData = nmsData || {
  old: {}, // Single copy of previous data. Automatically populated.
  stats: {
    identicalFetches: 0,
    outstandingAjaxRequests: 0,
    ajaxOverflow: 0,
    pollClearsEmpty: 0,
    pollClears: 0,
    pollSets: 0,
    newSource: 0,
    oldSource: 0,
    allFetches: 0,
    allRequests: 0,
    failedFetches: 0,
    jsonParse: 0,
  },
  _pulseBeat: 0,
  /*
   * The last time stamp of any data received, regardless of source.
   *
   * Used as a fallback for blank now, but can also be used to check
   * "freshness", I suppose.
   */
  _last: undefined,
  _now: undefined,

  /*
   * These are provided so we can introduce error checking when we
   * have time.
   *
   * now() represents the data, not the intent. That means that if
   * you want to check if we are traveling in time you should not
   * check nmsData.now. That will always return a value as long as
   * we've had a single piece of data.
   */
  get now() {
    return this._now || this._last;
  },
  set now(val) {
    if (val == undefined || !val) {
      nmsData._now = undefined;
    } else {
      // FIXME: Check if now is valid syntax.
      nmsData._now = val;
    }
  },
  /*
   * List of sources, name, handler, etc
   */
  _sources: {},

  /*
   * Maximum number of AJAX requests in transit before we start
   * skipping updates.
   *
   * A problem right now is that it will typically always hit the
   * same thing since everything starts at the same time...
   */
  _ajaxThreshold: 10,
};

nmsData._dropData = function (name) {
  delete this[name];
  delete this.old[name];
};

nmsData.removeSource = function (name) {
  if (this._sources[name] == undefined) {
    this.stats.pollClearsEmpty++;
    return true;
  }
  if (this._sources[name]["handle"]) {
    this.stats.pollClears++;
    clearInterval(this._sources[name]["handle"]);
  }
  delete this._sources[name];
};

/*
 * Register a source.
 *
 * name: "Local" name. Maps to nmsData[name]
 * target: URL of the source
 *
 * This can be called multiple times to add multiple handlers. There's no
 * guarantee that they will be run in order, but right now they do.
 *
 * Update frequency _might_ be adaptive eventually, but since we only
 * execute callbacks on change and backend sends cache headers, the browser
 * will not issue actual HTTP requests.
 *
 * FIXME: Should be unified with nmsTimers() somehow.
 */
nmsData.registerSource = function (name, target) {
  if (this._sources[name] == undefined) {
    this._sources[name] = { target: target, cbs: {}, fresh: true };
    this._sources[name]["handle"] = setInterval(function () {
      nmsData.updateSource(name);
    }, 1000);
    this.stats.newSource++;
  } else {
    this.stats.oldSource++;
  }
  this.stats.pollSets++;
};

/*
 * Show sign-of-life to the user.
 *
 * Now that we don't show the date field constantly it is nice to indicate
 * to the user that things are still running in some discreet manner.
 *
 * The actual html might not be the best choice, but I think the general
 * idea of some sort of heartbeat is needed.
 */
nmsData._pulse = function () {
  if (nmsData._pulseElement == undefined) {
    try {
      nmsData._pulseElement = document.getElementById("heartbeat");
    } catch (e) {
      nmsData._pulseElement = null;
    }
  }
  if (nmsData._pulseElement == null) return;
  if (nmsData._pulseBeat > 20) {
    if (nmsData._pulseElement.classList.contains("pulse-on")) {
      nmsData._pulseElement.classList.remove("pulse-on");
    } else {
      nmsData._pulseElement.classList.add("pulse-on");
    }
    nmsData._pulseBeat = 0;
  }
  nmsData._pulseBeat++;
};
/*
 * Add a handler (callback) for a source, using an id.
 *
 * This is idempotent: if the id is the same, it will just overwrite the
 * old id, not add a copy.
 */
nmsData.addHandler = function (name, id, cb, cbdata) {
  var cbob = {
    id: id,
    name: name,
    cb: cb,
    fresh: true,
    cbdata: cbdata,
  };
  if (id == undefined) {
    return;
  }
  this._sources[name].cbs[id] = cbob;
  this.updateSource(name);
};

/*
 * Unregister all handlers with the "id" for all sources.
 *
 * Mainly used to avoid fini() functions in the map handlers. E.g.: just
 * reuse "mapHandler" as id.
 */
nmsData.unregisterHandlerWildcard = function (id) {
  for (var v in nmsData._sources) {
    this.unregisterHandler(v, id);
  }
};

nmsData.unregisterHandler = function (name, id) {
  delete this._sources[name].cbs[id];
};

/*
 * Updates a source.
 *
 * Called on interval, but can also be used to update a source after a
 * known action that updates the underlying data (e.g: update comments
 * after a comment is posted).
 */
nmsData.updateSource = function (name) {
  /*
   * See comment in nms.js nmsINIT();
   */
  if (name == "ticker") {
    for (var i in nmsData._sources[name].cbs) {
      var tmp = nmsData._sources[name].cbs[i];
      if (tmp.cb != undefined) {
        tmp.cb(tmp.cbdata);
      }
    }
    return;
  }
  this._genericUpdater(name, true);
};

nmsData.invalidate = function (name) {
  this._genericUpdater(name, false);
};
/*
 * Reset a source, deleting all data, including old.
 *
 * Useful if traveling in time, for example.
 */
nmsData.resetSource = function (name) {
  this[name] = {};
  this.old[name] = {};
  this.updateSource(name);
};

/*
 * Updates nmsData[name] and nmsData.old[name], issuing any callbacks where
 * relevant.
 *
 * Do not use this directly. Use updateSource().
 *
 */
nmsData._genericUpdater = function (name, cacheok) {
  if (this.stats.outstandingAjaxRequests++ > this._ajaxThreshold) {
    this.stats.outstandingAjaxRequests--;
    this.stats.ajaxOverflow++;
    return;
  }
  var now = "";
  if (this._now != undefined) now = "now=" + this._now;
  if (now != "") {
    if (this._sources[name].target.match("\\?")) now = "&" + now;
    else now = "?" + now;
  }

  // TODO
  var heads = {};
  if (cacheok == false) {
    heads["Cache-Control"] = "max-age=0, no-cache, stale-while-revalidate=0";
  }

  /*
   *
   * We can be smarter than fetch here. We know that the ETag can be
   * used to evaluate against our cached copy. If the ETag is a
   * match, we never have to do the potentially extensive JSON
   * parsing.
   *
   * Also note that we accept weakened ETags too (ETags with W/
   * prefixed). This is typically if the intermediate cache has
   * compressed the content for us, so this is fine. DO WE?
   *
   * This is particularly important because we poll everything even
   * though we _know_ it will hit both browser cache and most likely
   * Varnish. JSON.Parse was one of the biggest CPU hogs before this.
   */

  const request = new Request(this._sources[name].target + now, {
    method: "GET",
    headers: { "Content-Type": "application/json", "If-None-Match": nmsData[name] != undefined ? nmsData[name]["hash"] : null },
    signal: AbortSignal.timeout(2000), // 2s timeout
  });

  nmsData.stats.allRequests++;
  fetch(request)
    .then((r) => {
      nmsData.stats.allFetches++;
      if (!r.ok && r.status != 304) {
        throw new Error("Fetch failed with status: " + r.status);
      }
      var etag = r.headers.get("etag");
      if (
        r.status != 304 && (
        etag == null ||
        nmsData[name] == undefined ||
        (nmsData[name]["hash"] != etag &&
          nmsData[name]["hash"] != etag.slice(2))
        )
      ) {
        r.json().then((data) => {
          nmsData.stats.jsonParse++;
          if (name == "ping") {
            nmsData._last = data["time"];
            nmsMap.drawNow();
          }
          nmsData.old[name] = nmsData[name];
          nmsData[name] = data;
          for (var i in nmsData._sources[name].cbs) {
            var tmp2 = nmsData._sources[name].cbs[i];
            if (tmp2.cb != undefined) {
              tmp2.cb(tmp2.cbdata);
            }
          }
        });
      } else {
        for (var j in nmsData._sources[name].cbs) {
          var tmp = nmsData._sources[name].cbs[j];
          if (tmp.cb != undefined && tmp.fresh) {
            nmsData._sources[name].cbs[j].fresh = false;
            tmp.cb(tmp.cbdata);
          }
        }
        nmsData.stats.identicalFetches++;
      }
    })
    .catch((err) => {
      nmsData.stats.failedFetches++;
      console.log("(" + name + "): " + err);
    })
    .finally(() => {
      nmsData._pulse();
      nmsData.stats.outstandingAjaxRequests--;
    });
};