From 0c7e9bf4568c6c5b73d5c69b6b1b5eb26b63dda1 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sat, 19 Sep 2020 18:17:20 +0300 Subject: [PATCH 01/24] * Require created_at for Treatments on insert * Refactoring the cache: moved to another file and support flushing from bus events --- lib/api/devicestatus/index.js | 2 +- lib/api/entries/index.js | 8 +-- lib/api/treatments/index.js | 24 ++++++-- lib/client/careportal.js | 2 + lib/constants.json | 1 + lib/data/dataloader.js | 26 ++++---- lib/data/ddata.js | 1 - lib/server/bootevent.js | 1 + lib/server/cache.js | 67 ++++++++++++++++++++ lib/server/treatments.js | 54 +++++++++++++++- npm-shrinkwrap.json | 112 +++++++++++++++++----------------- package.json | 1 + tests/api.treatments.test.js | 29 +++++++-- 13 files changed, 245 insertions(+), 83 deletions(-) create mode 100644 lib/server/cache.js diff --git a/lib/api/devicestatus/index.js b/lib/api/devicestatus/index.js index bdddfae9212..94dfb1c9385 100644 --- a/lib/api/devicestatus/index.js +++ b/lib/api/devicestatus/index.js @@ -47,7 +47,7 @@ function configure (app, wares, ctx, env) { q.count = 10; } - const inMemoryData = ctx.ddata.shadow.devicestatus ? ctx.ddata.shadow.devicestatus : []; + const inMemoryData = ctx.cache.devicestatus ? ctx.cache.devicestatus : []; const canServeFromMemory = inMemoryData.length >= q.count && Object.keys(q).length == 1 ? true : false; if (canServeFromMemory) { diff --git a/lib/api/entries/index.js b/lib/api/entries/index.js index 088bb86d3af..8cbf99ff30f 100644 --- a/lib/api/entries/index.js +++ b/lib/api/entries/index.js @@ -456,11 +456,11 @@ function configure (app, wares, ctx, env) { let inMemoryCollection; if (typeQuery) { - if (typeQuery == 'sgv') inMemoryCollection = ctx.ddata.shadow.sgvs; - if (typeQuery == 'mbg') inMemoryCollection = ctx.ddata.shadow.mbgs; - if (typeQuery == 'cal') inMemoryCollection = ctx.ddata.shadow.cals; + if (typeQuery == 'sgv') inMemoryCollection = ctx.cache.sgvs; + if (typeQuery == 'mbg') inMemoryCollection = ctx.cache.mbgs; + if (typeQuery == 'cal') inMemoryCollection = ctx.cache.cals; } else { - const merged = _.unionWith(ctx.ddata.shadow.sgvs, ctx.ddata.shadow.mbgs, ctx.ddata.shadow.cals, function(a, b) { + const merged = _.unionWith(ctx.cache.sgvs, ctx.cache.mbgs, ctx.cache.cals, function(a, b) { return a._id == b._id; }); inMemoryCollection = _.sortBy(merged, function(item) { diff --git a/lib/api/treatments/index.js b/lib/api/treatments/index.js index 0d318301257..df3cc6d7503 100644 --- a/lib/api/treatments/index.js +++ b/lib/api/treatments/index.js @@ -91,7 +91,7 @@ function configure (app, wares, ctx, env) { query.count = query.find ? 1000 : 100; } - const inMemoryData = ctx.ddata.shadow.treatments; + const inMemoryData = ctx.cache.treatments; const canServeFromMemory = inMemoryData && inMemoryData.length >= query.count && Object.keys(query).length == 1 ? true : false; if (canServeFromMemory) { @@ -112,12 +112,27 @@ function configure (app, wares, ctx, env) { treatments = [treatments]; } + for (let i = 0; i < treatments.length; i++) { + const t = treatments[i]; + if (!t.created_at) { + console.log('Trying to create treatment without created_at field', t); + res.sendJSONStatus(res, constants.HTTP_VALIDATION_ERROR, 'Treatments must contain created_at'); + return; + } + const d = moment(t.created_at); + if (!d.isValid()) { + console.log('Trying to insert date with invalid created_at', t); + res.sendJSONStatus(res, constants.HTTP_VALIDATION_ERROR, 'Treatments created_at must be an ISO-8601 date'); + return; + } + } + ctx.treatments.create(treatments, function(err, created) { if (err) { console.log('Error adding treatment', err); res.sendJSONStatus(res, constants.HTTP_INTERNAL_ERROR, 'Mongo Error', err); } else { - console.log('Treatment created'); + console.log('REST API treatment created', created); res.json(created); } }); @@ -146,11 +161,12 @@ function configure (app, wares, ctx, env) { console.log('treatments delete error: ', err); return next(err); } + + console.log('treatments records deleted', query); + // yield some information about success of operation res.json(stat); - console.log('treatments records deleted'); - return next(); }); } diff --git a/lib/client/careportal.js b/lib/client/careportal.js index e2cbe1c1208..a6edfe3ec22 100644 --- a/lib/client/careportal.js +++ b/lib/client/careportal.js @@ -283,6 +283,8 @@ function init (client, $) { data.eventTime = mergeDateAndTime().toDate(); } + data.created_at = data.eventTime ? data.eventTime.toISOString() : new Date().toISOString(); + if (!inputMatrix[data.eventType].profile) { delete data.profile; } diff --git a/lib/constants.json b/lib/constants.json index 2f0375c49c6..38e36040d42 100644 --- a/lib/constants.json +++ b/lib/constants.json @@ -4,6 +4,7 @@ "HTTP_UNAUTHORIZED" : 401, "HTTP_VALIDATION_ERROR" : 422, "HTTP_INTERNAL_ERROR" : 500, + "HTTP_BAD_REQUEST": 400, "ENTRIES_DEFAULT_COUNT" : 10, "PROFILES_DEFAULT_COUNT" : 10, "MMOL_TO_MGDL": 18, diff --git a/lib/data/dataloader.js b/lib/data/dataloader.js index 511a844c147..cf3ae46d840 100644 --- a/lib/data/dataloader.js +++ b/lib/data/dataloader.js @@ -145,7 +145,7 @@ function init(env, ctx) { } // clear treatments to the base set, we're going to merge from multiple queries - ddata.treatments = ddata.shadow.treatments ? _.cloneDeep(ddata.shadow.treatments) : []; + ddata.treatments = ctx.cache.treatments ? _.cloneDeep(ctx.cache.treatments) : []; ddata.dbstats = {}; @@ -248,9 +248,9 @@ function loadEntries(ddata, ctx, callback) { ddata.sgvs = mergeProcessSort(ddata.sgvs, sgvs, ageLimit); ddata.mbgs = mergeProcessSort(ddata.mbgs, uniqBasedOnMills(mbgs), ageLimit); ddata.cals = mergeProcessSort(ddata.cals, uniqBasedOnMills(cals), ageLimit); - ddata.shadow.sgvs = mergeProcessSort(ddata.shadow.sgvs, shadowSgvs, ageLimit).reverse(); - ddata.shadow.mbgs = mergeProcessSort(ddata.shadow.mbgs, uniqBasedOnMills(shadowMbgs), ageLimit).reverse(); - ddata.shadow.cals = mergeProcessSort(ddata.shadow.cals, uniqBasedOnMills(shadowCals), ageLimit).reverse(); + ctx.cache.sgvs = mergeProcessSort(ctx.cache.sgvs, shadowSgvs, ageLimit).reverse(); + ctx.cache.mbgs = mergeProcessSort(ctx.cache.mbgs, uniqBasedOnMills(shadowMbgs), ageLimit).reverse(); + ctx.cache.cals = mergeProcessSort(ctx.cache.cals, uniqBasedOnMills(shadowCals), ageLimit).reverse(); } callback(); }); @@ -309,9 +309,13 @@ function loadTreatments(ddata, ctx, callback) { // Load 2.5 days to cover last 48 hours including overlapping temp boluses or temp targets for first load // Subsequently load at least 15 minutes of data, but if latest entry is older than 15 minutes, load until that entry - const loadPeriod = ddata.treatments && ddata.treatments.length > 0 && !withFrame ? constants.SIX_HOURS : longLoad; - const latestEntry = ddata.treatments && ddata.treatments.length > 0 ? findLatestMills(ddata.treatments) : ddata.lastUpdated; - const loadTime = Math.min(ddata.lastUpdated - loadPeriod, latestEntry); + let loadTime = ddata.lastUpdated - longLoad; + + if (ctx.cache.treatments.length > 0) { + const loadPeriod = ddata.treatments && ddata.treatments.length > 0 && !withFrame ? constants.SIX_HOURS : longLoad; + const latestEntry = ddata.treatments && ddata.treatments.length > 0 ? findLatestMills(ddata.treatments) : ddata.lastUpdated; + loadTime = Math.min(ddata.lastUpdated - loadPeriod, latestEntry); + } var dateRange = { $gte: new Date(loadTime).toISOString() @@ -331,9 +335,9 @@ function loadTreatments(ddata, ctx, callback) { ctx.treatments.list(tq, function(err, results) { if (!err && results) { const ageFilter = ddata.lastUpdated - longLoad; - ddata.treatments = mergeProcessSort(ddata.treatments, results, ageFilter); - ddata.shadow.treatments = mergeProcessSort(ddata.shadow.treatments, results, ageFilter).reverse(); //.reverse(); - //mergeToTreatments(ddata, results); + // update cache + ctx.cache.treatments = mergeProcessSort(ctx.cache.treatments, results, ageFilter).reverse(); + ddata.treatments = mergeProcessSort(ddata.treatments, _.cloneDeep(ctx.cache.treatments), ageFilter); } callback(); @@ -487,7 +491,7 @@ function loadDeviceStatus(ddata, env, ctx, callback) { ctx.devicestatus.list(opts, function(err, results) { if (!err && results) { const ageFilter = ddata.lastUpdated - constants.TWO_DAYS; - ddata.shadow.devicestatus = mergeProcessSort(ddata.shadow.devicestatus, results, ageFilter); + ctx.cache.devicestatus = mergeProcessSort(ctx.cache.devicestatus, results, ageFilter); const r = _.map(results, function eachStatus(result) { //result.mills = new Date(result.created_at).getTime(); diff --git a/lib/data/ddata.js b/lib/data/ddata.js index 265bca5789e..ed0830b270a 100644 --- a/lib/data/ddata.js +++ b/lib/data/ddata.js @@ -19,7 +19,6 @@ function init () { , activity: [] , dbstats: {} , lastUpdated: 0 - , shadow: {} }; ddata.clone = function clone () { diff --git a/lib/server/bootevent.js b/lib/server/bootevent.js index f5702efc4fe..17f0319b360 100644 --- a/lib/server/bootevent.js +++ b/lib/server/bootevent.js @@ -176,6 +176,7 @@ function boot (env, language) { ctx.properties = require('../api/properties')(env, ctx); ctx.bus = require('../bus')(env.settings, ctx); ctx.ddata = require('../data/ddata')(); + ctx.cache = require('./cache')(env,ctx); ctx.dataloader = require('../data/dataloader')(env, ctx); ctx.notifications = require('../notifications')(env, ctx); diff --git a/lib/server/cache.js b/lib/server/cache.js new file mode 100644 index 00000000000..cf6ec7b8f6b --- /dev/null +++ b/lib/server/cache.js @@ -0,0 +1,67 @@ +'use strict'; + +var _ = require('lodash'); + +function cache (env, ctx) { + + const data = { + treatments : [], + devicestatus : [], + sgvs : [], + cals : [], + mbgs : [] + }; + + const dataArray = [ + data.treatments, + data.devicestatus, + data.sgvs, + data.cals, + data.mbgs + ]; + + function match(o1, o2) { + return o1._id == o2._id; + } + + function dataChanged(operation) { + console.log('Cache data update event', data); + + if (operation.op == 'remove') { + console.log('Cache data delete event'); + // if multiple items were deleted, flush entire cache + if (!operation.changes) { + console.log('Multiple items delete from cache, flushing all') + data[operation.op.type] = []; + } else { + removeFromArray(data[operation.type], operation.changes); + } + } + + if (operation.op == 'update') { + console.log('Cache data update event'); + } + } + + ctx.bus.on('data-update', dataChanged); + + function removeFromArray(array, id) { + for (let i = 0; i < array.length; i++) { + const o = array[i]; + if (o._id == id) { + console.log('Deleting object from cache', id); + array.splice(i,1); + break; + } + } + } + + cache.updateObject = (o) => { + + } + + return data; + +} + +module.exports = cache; diff --git a/lib/server/treatments.js b/lib/server/treatments.js index 3edd00a155e..c4ae785767d 100644 --- a/lib/server/treatments.js +++ b/lib/server/treatments.js @@ -3,7 +3,6 @@ var _ = require('lodash'); var async = require('async'); var moment = require('moment'); - var find_options = require('./query'); function storage (env, ctx) { @@ -48,12 +47,18 @@ function storage (env, ctx) { }; api( ).update(query, obj, {upsert: true}, function complete (err, updateResults) { + + if (err) console.error('Problem upserting treatment', err); + if (!err) { if (updateResults.result.upserted) { obj._id = updateResults.result.upserted[0]._id + console.log('PERSISTENCE: treatment upserted', updateResults.result.upserted[0]); } + console.log('Update result', updateResults.result); } + // TODO document this feature if (!err && obj.preBolus) { //create a new object to insert copying only the needed fields var pbTreat = { @@ -69,9 +74,23 @@ function storage (env, ctx) { query.created_at = pbTreat.created_at; api( ).update(query, pbTreat, {upsert: true}, function pbComplete (err) { var treatments = _.compact([obj, pbTreat]); + + ctx.bus.emit('data-update', { + type: 'treatments', + op: 'update', + changes: treatments + }); + fn(err, treatments); }); } else { + + ctx.bus.emit('data-update', { + type: 'treatments', + op: 'update', + changes: [obj] + }); + fn(err, [obj]); } @@ -99,7 +118,16 @@ function storage (env, ctx) { function remove (opts, fn) { return api( ).remove(query_for(opts), function (err, stat) { - //TODO: this is triggering a read from Mongo, we can do better + //TODO: this is triggering a read from Mongo, we can do better + console.log('Treatment removed', opts); // , stat); + + ctx.bus.emit('data-update', { + type: 'treatments', + op: 'remove', + count: stat.result.n, + changes: opts.find._id + }); + ctx.bus.emit('data-received'); fn(err, stat); }); @@ -108,6 +136,23 @@ function storage (env, ctx) { function save (obj, fn) { obj._id = new ObjectID(obj._id); prepareData(obj); + + function saved (err, created) { + if (!err) { +// console.log('Treatment updated', created); + + ctx.bus.emit('data-update', { + type: 'treatments', + op: 'update', + changes: [obj] + }); + + } + if (err) console.error('Problem saving treating', err); + + fn(err, created); + } + api().save(obj, fn); ctx.bus.emit('data-received'); @@ -147,6 +192,7 @@ function prepareData(obj) { // Convert all dates to UTC dates + // TODO remove this -> must not create new date if missing const d = moment(obj.created_at).isValid() ? moment.parseZone(obj.created_at) : moment(); obj.created_at = d.toISOString(); @@ -170,7 +216,7 @@ function prepareData(obj) { obj.relative = Number(obj.relative); obj.preBolus = Number(obj.preBolus); - //NOTE: the eventTime is sent by the client, but deleted, we only store created_at right now + //NOTE: the eventTime is sent by the client, but deleted, we only store created_at var eventTime; if (obj.eventTime) { eventTime = new Date(obj.eventTime).toISOString(); @@ -220,6 +266,8 @@ function prepareData(obj) { delete obj.units; } + console.log('Preparing treatment for insertion, obj', obj, 'results', results); + return results; } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 0c16f367bd4..106d5676bac 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1551,30 +1551,6 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz", "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==" }, - "axios": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", - "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", - "requires": { - "follow-redirects": "1.5.10" - } - }, - "axios-cookiejar-support": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-1.0.0.tgz", - "integrity": "sha512-9pBlIU5jfrGZTnUQlt8symShviSTOSlOKGtryHx76lJPnKIXDqUT3JDAjJ1ywOQLyfiWrthIt4iJiVP2L2S4jA==", - "requires": { - "is-redirect": "^1.0.0", - "pify": "^5.0.0" - }, - "dependencies": { - "pify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", - "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==" - } - } - }, "babel-code-frame": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", @@ -4227,24 +4203,6 @@ "readable-stream": "^2.3.6" } }, - "follow-redirects": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", - "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", - "requires": { - "debug": "=3.1.0" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "requires": { - "ms": "2.0.0" - } - } - } - }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -5179,7 +5137,8 @@ "is-redirect": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", - "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=" + "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=", + "dev": true }, "is-regex": { "version": "1.1.1", @@ -6399,6 +6358,23 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, + "axios-cookiejar-support": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-1.0.0.tgz", + "integrity": "sha512-9pBlIU5jfrGZTnUQlt8symShviSTOSlOKGtryHx76lJPnKIXDqUT3JDAjJ1ywOQLyfiWrthIt4iJiVP2L2S4jA==", + "requires": { + "is-redirect": "^1.0.0", + "pify": "^5.0.0" + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -6668,6 +6644,29 @@ "is-buffer": "~2.0.3" } }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -6818,6 +6817,11 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" }, + "is-redirect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz", + "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=" + }, "is-regex": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", @@ -7147,6 +7151,11 @@ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, + "pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==" + }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -7330,13 +7339,6 @@ "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.1.2" - }, - "dependencies": { - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - } } }, "tunnel-agent": { @@ -7352,6 +7354,11 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, "uri-js": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", @@ -11067,11 +11074,6 @@ "crypto-random-string": "^1.0.0" } }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" - }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 24c65e97f88..2a764808302 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "scripts": { "start": "node server.js", "test": "env-cmd ./my.test.env mocha --exit tests/*.test.js", + "test-single": "env-cmd ./my.test.env mocha --exit tests/$TEST.test.js", "test-ci": "env-cmd ./ci.test.env nyc --reporter=lcov --reporter=text-summary mocha --exit tests/*.test.js", "env": "env", "postinstall": "webpack --mode production --config webpack.config.js && npm run-script update-buster", diff --git a/tests/api.treatments.test.js b/tests/api.treatments.test.js index 4ba3739f4c0..0c78267971d 100644 --- a/tests/api.treatments.test.js +++ b/tests/api.treatments.test.js @@ -34,10 +34,11 @@ describe('Treatment API', function ( ) { it('post single treatments', function (done) { self.ctx.treatments().remove({ }, function ( ) { + var now = (new Date()).toISOString(); request(self.app) .post('/api/treatments/') .set('api-secret', self.env.api_secret || '') - .send({eventType: 'Meal Bolus', carbs: '30', insulin: '2.00', preBolus: '15', glucose: 100, glucoseType: 'Finger', units: 'mg/dl'}) + .send({eventType: 'Meal Bolus', created_at: now, carbs: '30', insulin: '2.00', preBolus: '15', glucose: 100, glucoseType: 'Finger', units: 'mg/dl'}) .expect(200) .end(function (err) { if (err) { @@ -61,6 +62,24 @@ describe('Treatment API', function ( ) { }); }); + it('saving entry without created_at should fail', function (done) { + + self.ctx.treatments().remove({ }, function ( ) { + request(self.app) + .post('/api/treatments/') + .set('api-secret', self.env.api_secret || '') + .send({eventType: 'Meal Bolus', carbs: '30', insulin: '2.00', preBolus: '15', glucose: 100, glucoseType: 'Finger', units: 'mg/dl'}) + .expect(422) + .end(function (err) { + if (err) { + done(err); + } else { + done(); + } + }); + }); + }); + it('post single treatments in zoned time format', function (done) { @@ -101,12 +120,13 @@ describe('Treatment API', function ( ) { it('post a treatment array', function (done) { self.ctx.treatments().remove({ }, function ( ) { + var now = (new Date()).toISOString(); request(self.app) .post('/api/treatments/') .set('api-secret', self.env.api_secret || '') .send([ - {eventType: 'BG Check', glucose: 100, preBolus: '0', glucoseType: 'Finger', units: 'mg/dl', notes: ''} - , {eventType: 'Meal Bolus', carbs: '30', insulin: '2.00', preBolus: '15', glucose: 100, glucoseType: 'Finger', units: 'mg/dl'} + {eventType: 'BG Check', created_at: now, glucose: 100, preBolus: '0', glucoseType: 'Finger', units: 'mg/dl', notes: ''} + , {eventType: 'Meal Bolus', created_at: now, carbs: '30', insulin: '2.00', preBolus: '15', glucose: 100, glucoseType: 'Finger', units: 'mg/dl'} ]) .expect(200) .end(function (err) { @@ -168,10 +188,11 @@ describe('Treatment API', function ( ) { it('post a treatment, query, delete, verify gone', function (done) { // insert a treatment - needs to be unique from example data console.log('Inserting treatment entry'); + var now = (new Date()).toISOString(); request(self.app) .post('/api/treatments/') .set('api-secret', self.env.api_secret || '') - .send({eventType: 'Meal Bolus', carbs: '99', insulin: '2.00', preBolus: '15', glucose: 100, glucoseType: 'Finger', units: 'mg/dl'}) + .send({eventType: 'Meal Bolus', created_at: now, carbs: '99', insulin: '2.00', preBolus: '15', glucose: 100, glucoseType: 'Finger', units: 'mg/dl'}) .expect(200) .end(function (err) { if (err) { From f878ce115b6db45665004374035b3f3c265e9703 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 20 Sep 2020 18:36:19 +0300 Subject: [PATCH 02/24] Add support for CGM data and device statuses. Refactor dataloader to use the new model and reduce queries back down --- lib/api/entries/index.js | 21 ++++++---- lib/data/dataloader.js | 83 ++++++++++++++++--------------------- lib/data/ddata.js | 56 ++++++++++++++++++++++++- lib/server/cache.js | 85 +++++++++++++++++++++++++++----------- lib/server/devicestatus.js | 23 ++++++++++- lib/server/entries.js | 15 +++++++ lib/server/treatments.js | 10 +++-- 7 files changed, 205 insertions(+), 88 deletions(-) diff --git a/lib/api/entries/index.js b/lib/api/entries/index.js index 8cbf99ff30f..3f37811279b 100644 --- a/lib/api/entries/index.js +++ b/lib/api/entries/index.js @@ -315,8 +315,9 @@ function configure (app, wares, ctx, env) { function prepReqModel (req, model) { var type = model || 'sgv'; if (!req.query.find) { + req.query.find = { - type: type + type: { "$eq" : type } }; } else { req.query.find.type = type; @@ -447,6 +448,7 @@ function configure (app, wares, ctx, env) { Object.keys(query.find).forEach(function(key) { if (key == 'type') { typeQuery = query.find[key]["$eq"]; + if (!typeQuery) typeQuery = query.find.type; } else { inMemoryPossible = false; } @@ -456,14 +458,16 @@ function configure (app, wares, ctx, env) { let inMemoryCollection; if (typeQuery) { - if (typeQuery == 'sgv') inMemoryCollection = ctx.cache.sgvs; - if (typeQuery == 'mbg') inMemoryCollection = ctx.cache.mbgs; - if (typeQuery == 'cal') inMemoryCollection = ctx.cache.cals; - } else { - const merged = _.unionWith(ctx.cache.sgvs, ctx.cache.mbgs, ctx.cache.cals, function(a, b) { - return a._id == b._id; + inMemoryCollection= _.filter(ctx.cache.entries, function checkType (object) { + if (typeQuery == 'sgv') return 'sgv' in object; + if (typeQuery == 'mbg') return 'mbg' in object; + if (typeQuery == 'cal') return object.type === 'cal'; + return false; }); - inMemoryCollection = _.sortBy(merged, function(item) { + } else { + inMemoryCollection = ctx.cache.getData('entries'); + + inMemoryCollection = _.sortBy(inMemoryCollection, function(item) { return item.mills; }).reverse(); } @@ -475,7 +479,6 @@ function configure (app, wares, ctx, env) { } // If we get this far, query the database - // bias to entries, but allow expressing a preference var storage = req.storage || ctx.entries; // perform the query diff --git a/lib/data/dataloader.js b/lib/data/dataloader.js index cf3ae46d840..2ded134bf66 100644 --- a/lib/data/dataloader.js +++ b/lib/data/dataloader.js @@ -145,7 +145,7 @@ function init(env, ctx) { } // clear treatments to the base set, we're going to merge from multiple queries - ddata.treatments = ctx.cache.treatments ? _.cloneDeep(ctx.cache.treatments) : []; + ddata.treatments = []; // ctx.cache.treatments ? _.cloneDeep(ctx.cache.treatments) : []; ddata.dbstats = {}; @@ -170,11 +170,11 @@ function init(env, ctx) { function loadEntries(ddata, ctx, callback) { const withFrame = ddata.page && ddata.page.frame; - const loadPeriod = ddata.sgvs && ddata.sgvs.length > 0 && !withFrame ? constants.ONE_HOUR : constants.TWO_DAYS; - const latestEntry = ddata.sgvs && ddata.sgvs.length > 0 ? findLatestMills(ddata.sgvs) : ddata.lastUpdated; + const longLoad = Math.round(constants.TWO_DAYS); + const loadTime = ctx.cache.isEmpty('entries') || withFrame ? longLoad : constants.FIFTEEN_MINUTES; var dateRange = { - $gte: Math.min(ddata.lastUpdated - loadPeriod, latestEntry) + $gte: ddata.lastUpdated - loadTime }; if (withFrame) { dateRange['$lte'] = ddata.lastUpdated; @@ -195,14 +195,18 @@ function loadEntries(ddata, ctx, callback) { } if (!err && results) { + + const ageFilter = ddata.lastUpdated - constants.TWO_DAYS; + const r = ctx.ddata.processRawDataForRuntime(results); + ctx.cache.insertData('entries', r, ageFilter); + + const currentData = ctx.cache.getData('entries'); + const mbgs = []; const sgvs = []; const cals = []; - const shadowMbgs = []; - const shadowSgvs = []; - const shadowCals = []; - results.forEach(function(element) { + currentData.forEach(function(element) { if (element) { if (!element.mills) element.mills = element.date; if (element.mbg) { @@ -213,7 +217,6 @@ function loadEntries(ddata, ctx, callback) { device: element.device, type: 'mbg' }); - shadowMbgs.push(element); } else if (element.sgv) { sgvs.push({ _id: element._id, @@ -227,7 +230,6 @@ function loadEntries(ddata, ctx, callback) { rssi: element.rssi, type: 'sgv' }); - shadowSgvs.push(element); } else if (element.type === 'cal') { cals.push({ _id: element._id, @@ -237,20 +239,14 @@ function loadEntries(ddata, ctx, callback) { slope: element.slope, type: 'cal' }); - shadowCals.push(element); } } }); const ageLimit = ddata.lastUpdated - constants.TWO_DAYS; - - //stop using uniq for SGVs since we use buckets, also enables more detailed monitoring - ddata.sgvs = mergeProcessSort(ddata.sgvs, sgvs, ageLimit); - ddata.mbgs = mergeProcessSort(ddata.mbgs, uniqBasedOnMills(mbgs), ageLimit); - ddata.cals = mergeProcessSort(ddata.cals, uniqBasedOnMills(cals), ageLimit); - ctx.cache.sgvs = mergeProcessSort(ctx.cache.sgvs, shadowSgvs, ageLimit).reverse(); - ctx.cache.mbgs = mergeProcessSort(ctx.cache.mbgs, uniqBasedOnMills(shadowMbgs), ageLimit).reverse(); - ctx.cache.cals = mergeProcessSort(ctx.cache.cals, uniqBasedOnMills(shadowCals), ageLimit).reverse(); + ddata.sgvs = sgvs; + ddata.mbgs = mbgs; + ddata.cals = cals; } callback(); }); @@ -307,18 +303,12 @@ function loadTreatments(ddata, ctx, callback) { const longLoad = Math.round(constants.ONE_DAY * 2.5); //ONE_DAY * 2.5; // Load 2.5 days to cover last 48 hours including overlapping temp boluses or temp targets for first load - // Subsequently load at least 15 minutes of data, but if latest entry is older than 15 minutes, load until that entry + // Subsequently load at least 15 minutes of data - let loadTime = ddata.lastUpdated - longLoad; + const loadTime = ctx.cache.isEmpty('treatments') || withFrame ? longLoad : constants.FIFTEEN_MINUTES; - if (ctx.cache.treatments.length > 0) { - const loadPeriod = ddata.treatments && ddata.treatments.length > 0 && !withFrame ? constants.SIX_HOURS : longLoad; - const latestEntry = ddata.treatments && ddata.treatments.length > 0 ? findLatestMills(ddata.treatments) : ddata.lastUpdated; - loadTime = Math.min(ddata.lastUpdated - loadPeriod, latestEntry); - } - var dateRange = { - $gte: new Date(loadTime).toISOString() + $gte: new Date(ddata.lastUpdated - loadTime).toISOString() }; if (withFrame) { dateRange['$lte'] = new Date(ddata.lastUpdated).toISOString(); @@ -335,9 +325,11 @@ function loadTreatments(ddata, ctx, callback) { ctx.treatments.list(tq, function(err, results) { if (!err && results) { const ageFilter = ddata.lastUpdated - longLoad; + const r = ctx.ddata.processRawDataForRuntime(results); + // update cache - ctx.cache.treatments = mergeProcessSort(ctx.cache.treatments, results, ageFilter).reverse(); - ddata.treatments = mergeProcessSort(ddata.treatments, _.cloneDeep(ctx.cache.treatments), ageFilter); + ctx.cache.insertData('treatments', r, ageFilter); + ddata.treatments = ctx.ddata.idMergePreferNew(ddata.treatments, ctx.cache.getData('treatments')); } callback(); @@ -458,21 +450,12 @@ function loadFood(ddata, ctx, callback) { function loadDeviceStatus(ddata, env, ctx, callback) { - let loadPeriod = constants.ONE_DAY; - if(env.extendedSettings.devicestatus && env.extendedSettings.devicestatus.days && env.extendedSettings.devicestatus.days == 2) loadPeriod = constants.TWO_DAYS; - - const withFrame = ddata.page && ddata.page.frame ? true : false; - - if (!withFrame && ddata.devicestatus && ddata.devicestatus.length > 0) { - loadPeriod = constants.FIFTEEN_MINUTES; - } - - let latestEntry = ddata.devicestatus && ddata.devicestatus.length > 0 ? findLatestMills(ddata.devicestatus) : ddata.lastUpdated; - if (!latestEntry) latestEntry = ddata.lastUpdated; // TODO find out why report test fails withtout this - const loadTime = Math.min(ddata.lastUpdated - loadPeriod, latestEntry); + const withFrame = ddata.page && ddata.page.frame; + const longLoad = env.extendedSettings.devicestatus && env.extendedSettings.devicestatus.days && env.extendedSettings.devicestatus.days == 2 ? constants.TWO_DAYS : constants.ONE_DAY; + const loadTime = ctx.cache.isEmpty('devicestatus') || withFrame ? longLoad : constants.FIFTEEN_MINUTES; var dateRange = { - $gte: new Date( loadTime ).toISOString() + $gte: new Date( ddata.lastUpdated - loadTime ).toISOString() }; if (withFrame) { @@ -490,10 +473,15 @@ function loadDeviceStatus(ddata, env, ctx, callback) { ctx.devicestatus.list(opts, function(err, results) { if (!err && results) { - const ageFilter = ddata.lastUpdated - constants.TWO_DAYS; - ctx.cache.devicestatus = mergeProcessSort(ctx.cache.devicestatus, results, ageFilter); +// ctx.cache.devicestatus = mergeProcessSort(ctx.cache.devicestatus, results, ageFilter); - const r = _.map(results, function eachStatus(result) { + const ageFilter = ddata.lastUpdated - longLoad; + const r = ctx.ddata.processRawDataForRuntime(results); + ctx.cache.insertData('devicestatus', r, ageFilter); + + const res = ctx.cache.getData('devicestatus'); + + const res2 = _.map(res, function eachStatus(result) { //result.mills = new Date(result.created_at).getTime(); if ('uploaderBattery' in result) { result.uploader = { @@ -503,7 +491,8 @@ function loadDeviceStatus(ddata, env, ctx, callback) { } return result; }); - ddata.devicestatus = mergeProcessSort(ddata.devicestatus, r, ageFilter); + + ddata.devicestatus = mergeProcessSort(ddata.devicestatus, res2, ageFilter); } else { ddata.devicestatus = []; } diff --git a/lib/data/ddata.js b/lib/data/ddata.js index ed0830b270a..65120782fc0 100644 --- a/lib/data/ddata.js +++ b/lib/data/ddata.js @@ -21,6 +21,59 @@ function init () { , lastUpdated: 0 }; + /** + * Convert Mongo ids to strings and ensure all objects have the mills property for + * significantly faster processing than constant date parsing, plus simplified + * logic + */ + ddata.processRawDataForRuntime = (data) => { + + let obj = _.cloneDeep(data); + + Object.keys(obj).forEach(key => { + if (typeof obj[key] === 'object' && obj[key]) { + if (obj[key].hasOwnProperty('_id')) { + obj[key]._id = obj[key]._id.toString(); + } + if (obj[key].hasOwnProperty('created_at') && !obj[key].hasOwnProperty('mills')) { + obj[key].mills = new Date(obj[key].created_at).getTime(); + } + if (obj[key].hasOwnProperty('sysTime') && !obj[key].hasOwnProperty('mills')) { + obj[key].mills = new Date(obj[key].sysTime).getTime(); + } + } + }); + + return obj; + }; + + /** + * Merge two arrays based on _id string, preferring new objects when a collision is found + * @param {array} oldData + * @param {array} newData + */ + ddata.idMergePreferNew = (oldData, newData) => { + + if (!newData && oldData) return oldData; + if (!oldData && newData) return newData; + + const merged = _.cloneDeep(newData); + + for (let i = 0; i < oldData.length; i++) { + const oldElement = oldData[i]; + let found = false; + for (let j = 0; j < newData.length; j++) { + if (oldElement._id == newData[j]._id) { + found = true; + break; + } + } + if (!found) merged.push(oldElement); // Merge old object in, if it wasn't found in the new data + } + + return merged; + }; + ddata.clone = function clone () { return _.clone(ddata, function(value) { //special handling of mongo ObjectID's @@ -34,7 +87,7 @@ function init () { }); }; - ddata.dataWithRecentStatuses = function dataWithRecentStatuses() { + ddata.dataWithRecentStatuses = function dataWithRecentStatuses () { var results = {}; results.devicestatus = ddata.recentDeviceStatus(Date.now()); results.sgvs = ddata.sgvs; @@ -55,7 +108,6 @@ function init () { results.dbstats = ddata.dbstats; return results; - } ddata.recentDeviceStatus = function recentDeviceStatus (time) { diff --git a/lib/server/cache.js b/lib/server/cache.js index cf6ec7b8f6b..208abaca1c9 100644 --- a/lib/server/cache.js +++ b/lib/server/cache.js @@ -1,67 +1,102 @@ 'use strict'; -var _ = require('lodash'); +/* This is a simple cache intended to reduce the amount of load + * Nightscout puts on MongoDB. The cache is based on identifying + * elements based on the MongoDB _id field and implements simple + * semantics for adding data to the cache in the runtime, intended + * to be accessed by the persistence layer as data is inserted, updated + * or deleted, as well as the periodic dataloader, which polls Mongo + * for new inserts. + * + * Longer term, the cache is planned to allow skipping the Mongo polls + * altogether. + */ + +const _ = require('lodash'); +const constants = require('../constants'); function cache (env, ctx) { const data = { - treatments : [], - devicestatus : [], - sgvs : [], - cals : [], - mbgs : [] + treatments: [] + , devicestatus: [] + , entries: [] }; const dataArray = [ - data.treatments, - data.devicestatus, - data.sgvs, - data.cals, - data.mbgs + data.treatments + , data.devicestatus + , data.entries ]; - function match(o1, o2) { - return o1._id == o2._id; + + function mergeCacheArrays (oldData, newData, ageLimit) { + + var filtered = _.filter(newData, function hasId (object) { + const hasId = !_.isEmpty(object._id); + const isFresh = (ageLimit && object.mills >= ageLimit) || (!ageLimit); + return isFresh && hasId; + }); + + const merged = ctx.ddata.idMergePreferNew(oldData, filtered); + + return _.sortBy(merged, function(item) { + return item.mills; + }); + + } + + data.isEmpty = (datatype) => { + return data[datatype].length == 0; } - function dataChanged(operation) { - console.log('Cache data update event', data); + data.getData = (datatype) => { + return _.cloneDeep(data[datatype]); + } + + data.insertData = (datatype, newData, retentionPeriod) => { + console.log('inserting to cache', data[datatype].length); + data[datatype] = mergeCacheArrays(data[datatype], newData, retentionPeriod); + console.log('post inserting to cache', data[datatype].length); + } + + function dataChanged (operation) { + console.log('Cache data operation requested', operation); if (operation.op == 'remove') { console.log('Cache data delete event'); // if multiple items were deleted, flush entire cache if (!operation.changes) { console.log('Multiple items delete from cache, flushing all') - data[operation.op.type] = []; + data.treatments = []; + data.devicestatus = []; + data.entries = []; } else { removeFromArray(data[operation.type], operation.changes); } } - if (operation.op == 'update') { + if (operation.op == 'update') { console.log('Cache data update event'); - } + data[operation.type] = mergeCacheArrays(data[operation.type], operation.changes); + } } ctx.bus.on('data-update', dataChanged); - function removeFromArray(array, id) { + function removeFromArray (array, id) { for (let i = 0; i < array.length; i++) { const o = array[i]; if (o._id == id) { console.log('Deleting object from cache', id); - array.splice(i,1); + array.splice(i, 1); break; } } } - cache.updateObject = (o) => { - - } - return data; - + } module.exports = cache; diff --git a/lib/server/devicestatus.js b/lib/server/devicestatus.js index d35c6be87cb..f3515367428 100644 --- a/lib/server/devicestatus.js +++ b/lib/server/devicestatus.js @@ -18,6 +18,13 @@ function storage (collection, ctx) { fn(err.message, null); return; } + + ctx.bus.emit('data-update', { + type: 'devicestatus', + op: 'update', + changes: ctx.ddata.processRawDataForRuntime([doc]) + }); + fn(null, doc.ops); ctx.bus.emit('data-received'); }); @@ -68,7 +75,21 @@ function storage (collection, ctx) { } function remove (opts, fn) { - return api( ).remove(query_for(opts), fn); + + function removed(err, stat) { + + ctx.bus.emit('data-update', { + type: 'devicestatus', + op: 'remove', + count: stat.result.n, + changes: opts.find._id + }); + + fn(err, stat); + } + + return api( ).remove( + query_for(opts), removed); } function api() { diff --git a/lib/server/entries.js b/lib/server/entries.js index 02dd115bb95..f6b61024e7d 100644 --- a/lib/server/entries.js +++ b/lib/server/entries.js @@ -47,6 +47,14 @@ function storage(env, ctx) { function remove (opts, fn) { api( ).remove(query_for(opts), function (err, stat) { + + ctx.bus.emit('data-update', { + type: 'entries', + op: 'remove', + count: stat.result.n, + changes: opts.find._id + }); + //TODO: this is triggering a read from Mongo, we can do better ctx.bus.emit('data-received'); fn(err, stat); @@ -101,6 +109,13 @@ function storage(env, ctx) { var query = (doc.sysTime && doc.type) ? {sysTime: doc.sysTime, type: doc.type} : doc; api( ).update(query, doc, {upsert: true}, function (err) { firstErr = firstErr || err; + + ctx.bus.emit('data-update', { + type: 'entries', + op: 'update', + changes: ctx.ddata.processRawDataForRuntime([doc]) + }); + if (++totalCreated === numDocs) { //TODO: this is triggering a read from Mongo, we can do better ctx.bus.emit('data-received'); diff --git a/lib/server/treatments.js b/lib/server/treatments.js index c4ae785767d..013201e028c 100644 --- a/lib/server/treatments.js +++ b/lib/server/treatments.js @@ -78,7 +78,7 @@ function storage (env, ctx) { ctx.bus.emit('data-update', { type: 'treatments', op: 'update', - changes: treatments + changes: ctx.ddata.processRawDataForRuntime(treatments) }); fn(err, treatments); @@ -88,7 +88,7 @@ function storage (env, ctx) { ctx.bus.emit('data-update', { type: 'treatments', op: 'update', - changes: [obj] + changes: ctx.ddata.processRawDataForRuntime([obj]) }); fn(err, [obj]); @@ -141,10 +141,12 @@ function storage (env, ctx) { if (!err) { // console.log('Treatment updated', created); + ctx.ddata.processRawDataForRuntime(obj); + ctx.bus.emit('data-update', { type: 'treatments', op: 'update', - changes: [obj] + changes: ctx.ddata.processRawDataForRuntime([obj]) }); } @@ -153,7 +155,7 @@ function storage (env, ctx) { fn(err, created); } - api().save(obj, fn); + api().save(obj, saved); ctx.bus.emit('data-received'); } From a75c5a300a56160f7ef45c5fbc9053db4f865a4c Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 20 Sep 2020 19:14:33 +0300 Subject: [PATCH 03/24] Fix data order for REST API --- lib/data/dataloader.js | 2 +- lib/server/cache.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/data/dataloader.js b/lib/data/dataloader.js index 2ded134bf66..0f1b3c7bd86 100644 --- a/lib/data/dataloader.js +++ b/lib/data/dataloader.js @@ -200,7 +200,7 @@ function loadEntries(ddata, ctx, callback) { const r = ctx.ddata.processRawDataForRuntime(results); ctx.cache.insertData('entries', r, ageFilter); - const currentData = ctx.cache.getData('entries'); + const currentData = ctx.cache.getData('entries').reverse(); const mbgs = []; const sgvs = []; diff --git a/lib/server/cache.js b/lib/server/cache.js index 208abaca1c9..b80c37ddaab 100644 --- a/lib/server/cache.js +++ b/lib/server/cache.js @@ -41,7 +41,7 @@ function cache (env, ctx) { const merged = ctx.ddata.idMergePreferNew(oldData, filtered); return _.sortBy(merged, function(item) { - return item.mills; + return -item.mills; }); } From 1059232f160d6e242f79a4139d7406e88632bb58 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Mon, 21 Sep 2020 20:46:53 +0300 Subject: [PATCH 04/24] * Add cache update events to websocket API * Remove the validation for created_at in REST API ;( --- lib/api/treatments/index.js | 8 ++ lib/server/cache.js | 2 + lib/server/websocket.js | 201 ++++++++++++++++++++++------------- tests/api.treatments.test.js | 3 +- 4 files changed, 142 insertions(+), 72 deletions(-) diff --git a/lib/api/treatments/index.js b/lib/api/treatments/index.js index df3cc6d7503..6e669903801 100644 --- a/lib/api/treatments/index.js +++ b/lib/api/treatments/index.js @@ -114,6 +114,12 @@ function configure (app, wares, ctx, env) { for (let i = 0; i < treatments.length; i++) { const t = treatments[i]; + + if (!t.created_at) { + t.created_at = new Date().toISOString(); + } + + /* if (!t.created_at) { console.log('Trying to create treatment without created_at field', t); res.sendJSONStatus(res, constants.HTTP_VALIDATION_ERROR, 'Treatments must contain created_at'); @@ -125,6 +131,8 @@ function configure (app, wares, ctx, env) { res.sendJSONStatus(res, constants.HTTP_VALIDATION_ERROR, 'Treatments created_at must be an ISO-8601 date'); return; } + */ + } ctx.treatments.create(treatments, function(err, created) { diff --git a/lib/server/cache.js b/lib/server/cache.js index b80c37ddaab..84329864bbb 100644 --- a/lib/server/cache.js +++ b/lib/server/cache.js @@ -63,6 +63,8 @@ function cache (env, ctx) { function dataChanged (operation) { console.log('Cache data operation requested', operation); + if (!data[operation.type]) return; + if (operation.op == 'remove') { console.log('Cache data delete event'); // if multiple items were deleted, flush entire cache diff --git a/lib/server/websocket.js b/lib/server/websocket.js index 89b67c97a08..7a685ccde05 100644 --- a/lib/server/websocket.js +++ b/lib/server/websocket.js @@ -7,7 +7,7 @@ var ObjectID = require('mongodb').ObjectID; function init (env, ctx, server) { - function websocket ( ) { + function websocket () { return websocket; } @@ -25,12 +25,12 @@ function init (env, ctx, server) { // TODO: this would be better to have somehow integrated/improved var supportedCollections = { - 'treatments' : env.treatments_collection, - 'entries': env.entries_collection, - 'devicestatus': env.devicestatus_collection, - 'profile': env.profile_collection, - 'food': env.food_collection, - 'activity': env.activity_collection + 'treatments': env.treatments_collection + , 'entries': env.entries_collection + , 'devicestatus': env.devicestatus_collection + , 'profile': env.profile_collection + , 'food': env.food_collection + , 'activity': env.activity_collection }; // This is little ugly copy but I was unable to pass testa after making module from status and share with /api/v1/status @@ -38,7 +38,7 @@ function init (env, ctx, server) { var versionNum = 0; var verParse = /(\d+)\.(\d+)\.(\d+)*/.exec(env.version); if (verParse) { - versionNum = 10000 * parseInt(verParse[1]) + 100 * parseInt(verParse[2]) + 1 * parseInt(verParse[3]) ; + versionNum = 10000 * parseInt(verParse[1]) + 100 * parseInt(verParse[2]) + 1 * parseInt(verParse[3]); } var apiEnabled = env.api_secret ? true : false; @@ -63,14 +63,15 @@ function init (env, ctx, server) { return info; } - function start ( ) { + function start () { io = require('socket.io')({ - 'transports': ['xhr-polling'], 'log level': 0 + 'transports': ['xhr-polling'] + , 'log level': 0 }).listen(server, { //these only effect the socket.io.js file that is sent to the client, but better than nothing - 'browser client minification': true, - 'browser client etag': true, - 'browser client gzip': false + 'browser client minification': true + , 'browser client etag': true + , 'browser client gzip': false }); ctx.bus.on('teardown', function serverTeardown () { @@ -85,7 +86,7 @@ function init (env, ctx, server) { ctx.authorization.resolve({ api_secret: message.secret, token: message.token }, function resolved (err, result) { if (err) { - return callback( err, { + return callback(err, { read: false , write: false , write_treatment: false @@ -113,7 +114,7 @@ function init (env, ctx, server) { } } - function listeners ( ) { + function listeners () { io.sockets.on('connection', function onConnection (socket) { var socketAuthorization = null; var clientType = null; @@ -124,16 +125,15 @@ function init (env, ctx, server) { console.log(LOG_WS + 'Connection from client ID: ', socket.client.id, ' IP: ', remoteIP); io.emit('clients', ++watchers); - socket.on('ack', function onAck(level, group, silenceTime) { + socket.on('ack', function onAck (level, group, silenceTime) { ctx.notifications.ack(level, group, silenceTime, true); }); - socket.on('disconnect', function onDisconnect ( ) { + socket.on('disconnect', function onDisconnect () { io.emit('clients', --watchers); - console.log(LOG_WS + 'Disconnected client ID: ',socket.client.id); + console.log(LOG_WS + 'Disconnected client ID: ', socket.client.id); }); - function checkConditions (action, data) { var collection = supportedCollections[data.collection]; if (!collection) { @@ -168,10 +168,10 @@ function init (env, ctx, server) { socket.on('loadRetro', function loadRetro (opts, callback) { if (callback) { - callback( { result: 'success' } ); + callback({ result: 'success' }); } //TODO: use opts to only send delta for retro data - socket.emit('retroUpdate', {devicestatus: lastData.devicestatus}); + socket.emit('retroUpdate', { devicestatus: lastData.devicestatus }); console.info('sent retroUpdate', opts); }); @@ -185,30 +185,46 @@ function init (env, ctx, server) { // } // } socket.on('dbUpdate', function dbUpdate (data, callback) { - console.log(LOG_WS + 'dbUpdate client ID: ', socket.client.id, ' data: ', data); - var collection = supportedCollections[data.collection]; + console.log(LOG_WS + 'dbUpdate client ID: ', socket.client.id, ' data: ', data); + var collection = supportedCollections[data.collection]; var check = checkConditions('dbUpdate', data); if (check) { - if (callback) { - callback( check ); + if (callback) { + callback(check); } return; } - var id ; + var id; try { - id = new ObjectID(data._id); - } catch (err){ - console.error(err); + id = new ObjectID(data._id); + } catch (err) { + console.error(err); id = new ObjectID(); } - ctx.store.collection(collection).update( - { '_id': id }, - { $set: data.data } + + ctx.store.collection(collection).update({ '_id': id } + , { $set: data.data } + , function(err, results) { + + if (!err) { + ctx.store.collection(collection).findOne({ '_id': id } + , function(err, results) { + console.log('Got results', results); + if (!err) { + ctx.bus.emit('data-update', { + type: data.collection + , op: 'update' + , changes: ctx.ddata.processRawDataForRuntime([results]) + }); + } + }); + } + } ); if (callback) { - callback( { result: 'success' } ); + callback({ result: 'success' }); } ctx.bus.emit('data-received'); }); @@ -223,13 +239,13 @@ function init (env, ctx, server) { // } // } socket.on('dbUpdateUnset', function dbUpdateUnset (data, callback) { - console.log(LOG_WS + 'dbUpdateUnset client ID: ', socket.client.id, ' data: ', data); - var collection = supportedCollections[data.collection]; + console.log(LOG_WS + 'dbUpdateUnset client ID: ', socket.client.id, ' data: ', data); + var collection = supportedCollections[data.collection]; var check = checkConditions('dbUpdate', data); if (check) { - if (callback) { - callback( check ); + if (callback) { + callback(check); } return; } @@ -238,10 +254,25 @@ function init (env, ctx, server) { ctx.store.collection(collection).update( { '_id': objId }, { $unset: data.data } - ); + , function(err, results) { + + if (!err) { + ctx.store.collection(collection).findOne({ '_id': objId } + , function(err, results) { + console.log('Got results', results); + if (!err) { + ctx.bus.emit('data-update', { + type: data.collection + , op: 'update' + , changes: ctx.ddata.processRawDataForRuntime([results]) + }); + } + }); + } + }); if (callback) { - callback( { result: 'success' } ); + callback({ result: 'success' }); } ctx.bus.emit('data-received'); }); @@ -255,14 +286,14 @@ function init (env, ctx, server) { // } // } socket.on('dbAdd', function dbAdd (data, callback) { - console.log(LOG_WS + 'dbAdd client ID: ', socket.client.id, ' data: ', data); + console.log(LOG_WS + 'dbAdd client ID: ', socket.client.id, ' data: ', data); var collection = supportedCollections[data.collection]; var maxtimediff = times.mins(1).msecs; var check = checkConditions('dbAdd', data); if (check) { - if (callback) { - callback( check ); + if (callback) { + callback(check); } return; } @@ -278,7 +309,7 @@ function init (env, ctx, server) { if (data.collection === 'treatments') { var query; if (data.data.NSCLIENT_ID) { - query = { NSCLIENT_ID: data.data.NSCLIENT_ID }; + query = { NSCLIENT_ID: data.data.NSCLIENT_ID }; } else { query = { created_at: data.data.created_at @@ -286,19 +317,19 @@ function init (env, ctx, server) { }; } - // try to find exact match + // try to find exact match ctx.store.collection(collection).find(query).toArray(function findResult (err, array) { if (err || array.length > 0) { - console.log(LOG_DEDUP + 'Exact match'); - if (callback) { - callback([array[0]]); - } - return; + console.log(LOG_DEDUP + 'Exact match'); + if (callback) { + callback([array[0]]); + } + return; } - var selected = false; - var query_similiar = { - created_at: {$gte: new Date(new Date(data.data.created_at).getTime() - maxtimediff).toISOString(), $lte: new Date(new Date(data.data.created_at).getTime() + maxtimediff).toISOString()} + var selected = false; + var query_similiar = { + created_at: { $gte: new Date(new Date(data.data.created_at).getTime() - maxtimediff).toISOString(), $lte: new Date(new Date(data.data.created_at).getTime() + maxtimediff).toISOString() } }; if (data.data.insulin) { query_similiar.insulin = data.data.insulin; @@ -312,7 +343,7 @@ function init (env, ctx, server) { query_similiar.percent = data.data.percent; selected = true; } - if (data.data.absolute) { + if (data.data.absolute) { query_similiar.absolute = data.data.absolute; selected = true; } @@ -320,7 +351,7 @@ function init (env, ctx, server) { query_similiar.duration = data.data.duration; selected = true; } - if (data.data.NSCLIENT_ID) { + if (data.data.NSCLIENT_ID) { query_similiar.NSCLIENT_ID = data.data.NSCLIENT_ID; selected = true; } @@ -335,10 +366,7 @@ function init (env, ctx, server) { console.log(LOG_DEDUP + 'Found similiar', array[0]); array[0].created_at = data.data.created_at; var objId = new ObjectID(array[0]._id); - ctx.store.collection(collection).update( - { '_id': objId }, - { $set: {created_at: data.data.created_at} } - ); + ctx.store.collection(collection).update({ '_id': objId }, { $set: { created_at: data.data.created_at } }); if (callback) { callback([array[0]]); } @@ -352,6 +380,13 @@ function init (env, ctx, server) { console.log('treatments data insertion error: ', err.message); return; } + + ctx.bus.emit('data-update', { + type: data.collection + , op: 'update' + , changes: ctx.ddata.processRawDataForRuntime(doc.ops) + }); + if (callback) { callback(doc.ops); } @@ -359,7 +394,7 @@ function init (env, ctx, server) { }); }); }); - // devicestatus deduping + // devicestatus deduping } else if (data.collection === 'devicestatus') { var queryDev; if (data.data.NSCLIENT_ID) { @@ -385,8 +420,15 @@ function init (env, ctx, server) { console.log('devicestatus insertion error: ', err.message); return; } + + ctx.bus.emit('data-update', { + type: 'devicestatus' + , op: 'update' + , changes: ctx.ddata.processRawDataForRuntime(doc.ops) + }); + if (callback) { - callback(doc.ops); + callback(doc.ops); } ctx.bus.emit('data-received'); }); @@ -396,6 +438,13 @@ function init (env, ctx, server) { console.log(data.collection + ' insertion error: ', err.message); return; } + + ctx.bus.emit('data-update', { + type: data.collection + , op: 'update' + , changes: ctx.ddata.processRawDataForRuntime(doc.ops) + }); + if (callback) { callback(doc.ops); } @@ -409,24 +458,34 @@ function init (env, ctx, server) { // _id: 'some mongo record id' // } socket.on('dbRemove', function dbRemove (data, callback) { - console.log(LOG_WS + 'dbRemove client ID: ', socket.client.id, ' data: ', data); - var collection = supportedCollections[data.collection]; + console.log(LOG_WS + 'dbRemove client ID: ', socket.client.id, ' data: ', data); + var collection = supportedCollections[data.collection]; var check = checkConditions('dbUpdate', data); if (check) { - if (callback) { - callback( check ); + if (callback) { + callback(check); } return; } var objId = new ObjectID(data._id); - ctx.store.collection(collection).remove( - { '_id': objId } - ); + ctx.store.collection(collection).remove({ '_id': objId } + , function(err, stat) { + + if (!err) { + ctx.bus.emit('data-update', { + type: data.collection + , op: 'remove' + , count: stat.result.n + , changes: data._id + }); + + } + }); if (callback) { - callback( { result: 'success' } ); + callback({ result: 'success' }); } ctx.bus.emit('data-received'); }); @@ -479,7 +538,7 @@ function init (env, ctx, server) { }); } - websocket.update = function update ( ) { + websocket.update = function update () { // console.log(LOG_WS + 'running websocket.update'); if (lastData.sgvs) { var delta = calcData(lastData, ctx.ddata); @@ -511,8 +570,8 @@ function init (env, ctx, server) { } }; - start( ); - listeners( ); + start(); + listeners(); if (ctx.storageSocket) { ctx.storageSocket.init(io); diff --git a/tests/api.treatments.test.js b/tests/api.treatments.test.js index 0c78267971d..2b1d3ee877d 100644 --- a/tests/api.treatments.test.js +++ b/tests/api.treatments.test.js @@ -62,6 +62,7 @@ describe('Treatment API', function ( ) { }); }); + /* it('saving entry without created_at should fail', function (done) { self.ctx.treatments().remove({ }, function ( ) { @@ -79,7 +80,7 @@ describe('Treatment API', function ( ) { }); }); }); - +*/ it('post single treatments in zoned time format', function (done) { From 54008a71221d7c0bcbf9efe6b2367ce48ccb267c Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Tue, 22 Sep 2020 11:14:13 +0300 Subject: [PATCH 05/24] Remove excess logging --- lib/api/entries/index.js | 2 +- lib/api/treatments/index.js | 7 +------ lib/server/cache.js | 12 +++++------- lib/server/treatments.js | 10 ++++------ 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/lib/api/entries/index.js b/lib/api/entries/index.js index 3f37811279b..5075df6eded 100644 --- a/lib/api/entries/index.js +++ b/lib/api/entries/index.js @@ -317,7 +317,7 @@ function configure (app, wares, ctx, env) { if (!req.query.find) { req.query.find = { - type: { "$eq" : type } + type: type }; } else { req.query.find.type = type; diff --git a/lib/api/treatments/index.js b/lib/api/treatments/index.js index 6e669903801..0e1d8a0ede8 100644 --- a/lib/api/treatments/index.js +++ b/lib/api/treatments/index.js @@ -161,8 +161,6 @@ function configure (app, wares, ctx, env) { query.count = 10 } - console.log('Delete records with query: ', query); - // remove using the query ctx.treatments.remove(query, function(err, stat) { if (err) { @@ -170,8 +168,6 @@ function configure (app, wares, ctx, env) { return next(err); } - console.log('treatments records deleted', query); - // yield some information about success of operation res.json(stat); @@ -204,8 +200,7 @@ function configure (app, wares, ctx, env) { ctx.treatments.save(data, function(err, created) { if (err) { res.sendJSONStatus(res, constants.HTTP_INTERNAL_ERROR, 'Mongo Error', err); - console.log('Error saving treatment'); - console.log(err); + console.log('Error saving treatment', err); } else { res.json(created); console.log('Treatment saved', data); diff --git a/lib/server/cache.js b/lib/server/cache.js index 84329864bbb..fca93afafde 100644 --- a/lib/server/cache.js +++ b/lib/server/cache.js @@ -55,21 +55,19 @@ function cache (env, ctx) { } data.insertData = (datatype, newData, retentionPeriod) => { - console.log('inserting to cache', data[datatype].length); data[datatype] = mergeCacheArrays(data[datatype], newData, retentionPeriod); - console.log('post inserting to cache', data[datatype].length); } function dataChanged (operation) { - console.log('Cache data operation requested', operation); + //console.log('Cache data operation requested', operation); if (!data[operation.type]) return; if (operation.op == 'remove') { - console.log('Cache data delete event'); + //console.log('Cache data delete event'); // if multiple items were deleted, flush entire cache if (!operation.changes) { - console.log('Multiple items delete from cache, flushing all') + //console.log('Multiple items delete from cache, flushing all') data.treatments = []; data.devicestatus = []; data.entries = []; @@ -79,7 +77,7 @@ function cache (env, ctx) { } if (operation.op == 'update') { - console.log('Cache data update event'); + //console.log('Cache data update event'); data[operation.type] = mergeCacheArrays(data[operation.type], operation.changes); } } @@ -90,7 +88,7 @@ function cache (env, ctx) { for (let i = 0; i < array.length; i++) { const o = array[i]; if (o._id == id) { - console.log('Deleting object from cache', id); + //console.log('Deleting object from cache', id); array.splice(i, 1); break; } diff --git a/lib/server/treatments.js b/lib/server/treatments.js index 013201e028c..dad13b5b5d6 100644 --- a/lib/server/treatments.js +++ b/lib/server/treatments.js @@ -53,9 +53,9 @@ function storage (env, ctx) { if (!err) { if (updateResults.result.upserted) { obj._id = updateResults.result.upserted[0]._id - console.log('PERSISTENCE: treatment upserted', updateResults.result.upserted[0]); + //console.log('PERSISTENCE: treatment upserted', updateResults.result.upserted[0]); } - console.log('Update result', updateResults.result); + //console.log('Update result', updateResults.result); } // TODO document this feature @@ -119,7 +119,7 @@ function storage (env, ctx) { function remove (opts, fn) { return api( ).remove(query_for(opts), function (err, stat) { //TODO: this is triggering a read from Mongo, we can do better - console.log('Treatment removed', opts); // , stat); + //console.log('Treatment removed', opts); // , stat); ctx.bus.emit('data-update', { type: 'treatments', @@ -139,7 +139,7 @@ function storage (env, ctx) { function saved (err, created) { if (!err) { -// console.log('Treatment updated', created); + // console.log('Treatment updated', created); ctx.ddata.processRawDataForRuntime(obj); @@ -268,8 +268,6 @@ function prepareData(obj) { delete obj.units; } - console.log('Preparing treatment for insertion, obj', obj, 'results', results); - return results; } From 3f1081e3aa814c84eb5c632e9b477aa4c0a3705b Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Tue, 22 Sep 2020 12:44:31 +0300 Subject: [PATCH 06/24] Bump version to .4 --- npm-shrinkwrap.json | 2 +- package.json | 2 +- swagger.json | 2 +- swagger.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 106d5676bac..891da5af29c 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "nightscout", - "version": "14.0.3", + "version": "14.0.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 2a764808302..6a24379ef16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nightscout", - "version": "14.0.3", + "version": "14.0.4", "description": "Nightscout acts as a web-based CGM (Continuous Glucose Montinor) to allow multiple caregivers to remotely view a patients glucose data in realtime.", "license": "AGPL-3.0", "author": "Nightscout Team", diff --git a/swagger.json b/swagger.json index 4f4e1f2813a..ffed8fd699c 100755 --- a/swagger.json +++ b/swagger.json @@ -8,7 +8,7 @@ "info": { "title": "Nightscout API", "description": "Own your DData with the Nightscout API", - "version": "14.0.3", + "version": "14.0.4", "license": { "name": "AGPL 3", "url": "https://www.gnu.org/licenses/agpl.txt" diff --git a/swagger.yaml b/swagger.yaml index 0dff0a7c477..ce94fe876db 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -4,7 +4,7 @@ servers: info: title: Nightscout API description: Own your DData with the Nightscout API - version: 14.0.3 + version: 14.0.4 license: name: AGPL 3 url: 'https://www.gnu.org/licenses/agpl.txt' From de792d244dab7003769ccdefaa0070ce0add6650 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Thu, 24 Sep 2020 22:06:02 +0300 Subject: [PATCH 07/24] Bump version to 14.0.5 and fix #6050 --- lib/profilefunctions.js | 3 +++ npm-shrinkwrap.json | 2 +- package.json | 2 +- swagger.json | 2 +- swagger.yaml | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/profilefunctions.js b/lib/profilefunctions.js index 0d92a7135a0..9fac4984949 100644 --- a/lib/profilefunctions.js +++ b/lib/profilefunctions.js @@ -61,6 +61,9 @@ function init (profileData) { // preprocess the timestamps to seconds for a couple orders of magnitude faster operation profile.preprocessProfileOnLoad = function preprocessProfileOnLoad (container) { _.each(container, function eachValue (value) { + + if (value === null) return; + if (Object.prototype.toString.call(value) === '[object Array]') { profile.preprocessProfileOnLoad(value); } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 891da5af29c..3ba1035c896 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "nightscout", - "version": "14.0.4", + "version": "14.0.5", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6a24379ef16..7ae6694563a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nightscout", - "version": "14.0.4", + "version": "14.0.5", "description": "Nightscout acts as a web-based CGM (Continuous Glucose Montinor) to allow multiple caregivers to remotely view a patients glucose data in realtime.", "license": "AGPL-3.0", "author": "Nightscout Team", diff --git a/swagger.json b/swagger.json index ffed8fd699c..5d3504ecf60 100755 --- a/swagger.json +++ b/swagger.json @@ -8,7 +8,7 @@ "info": { "title": "Nightscout API", "description": "Own your DData with the Nightscout API", - "version": "14.0.4", + "version": "14.0.5", "license": { "name": "AGPL 3", "url": "https://www.gnu.org/licenses/agpl.txt" diff --git a/swagger.yaml b/swagger.yaml index ce94fe876db..1bd4a02a964 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -4,7 +4,7 @@ servers: info: title: Nightscout API description: Own your DData with the Nightscout API - version: 14.0.4 + version: 14.0.5 license: name: AGPL 3 url: 'https://www.gnu.org/licenses/agpl.txt' From 07eee47257486896f237c64c6f0aa7ecc97b66ed Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Thu, 24 Sep 2020 22:31:56 +0300 Subject: [PATCH 08/24] Fix Pebble API detecting mmol units from settings --- lib/server/pebble.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/pebble.js b/lib/server/pebble.js index 544aa878268..97b756a9b84 100644 --- a/lib/server/pebble.js +++ b/lib/server/pebble.js @@ -176,7 +176,7 @@ function configure (env, ctx) { req.rawbg = env.settings.isEnabled('rawbg'); req.iob = env.settings.isEnabled('iob'); req.cob = env.settings.isEnabled('cob'); - req.mmol = (req.query.units || env.DISPLAY_UNITS) === 'mmol'; + req.mmol = (req.query.units || env.settings.units) === 'mmol'; req.count = parseInt(req.query.count) || 1; next( ); From 8ac973471b88dcdc6cbb160ee66934ea0f0cdde8 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Fri, 25 Sep 2020 10:37:23 +0300 Subject: [PATCH 09/24] Make settings parsing whitespace tolerant --- env.js | 20 ++++++++++++++------ lib/settings.js | 4 +++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/env.js b/env.js index c753b29cd03..6bac00a5e51 100644 --- a/env.js +++ b/env.js @@ -14,9 +14,17 @@ var env = { settings: require('./lib/settings')() }; +var trimmedEnv = {}; + // Module to constrain all config and environment parsing to one spot. // See README.md for info about all the supported ENV VARs function config ( ) { + + // Assume users will typo whitespaces into keys and values + Object.keys(process.env).forEach((key, index) => { + trimmedEnv[_trim(key)] = _trim(process.env[key]); + }); + env.PORT = readENV('PORT', 1337); env.HOSTNAME = readENV('HOSTNAME', null); env.IMPORT_CONFIG = readENV('IMPORT_CONFIG', null); @@ -122,7 +130,7 @@ function updateSettings() { }); //should always find extended settings last - env.extendedSettings = findExtendedSettings(process.env); + env.extendedSettings = findExtendedSettings(trimmedEnv); if (!readENVTruthy('TREATMENTS_AUTH', true)) { env.settings.authDefaultRoles = env.settings.authDefaultRoles || ""; @@ -132,10 +140,10 @@ function updateSettings() { function readENV(varName, defaultValue) { //for some reason Azure uses this prefix, maybe there is a good reason - var value = process.env['CUSTOMCONNSTR_' + varName] - || process.env['CUSTOMCONNSTR_' + varName.toLowerCase()] - || process.env[varName] - || process.env[varName.toLowerCase()]; + var value = trimmedEnv['CUSTOMCONNSTR_' + varName] + || trimmedEnv['CUSTOMCONNSTR_' + varName.toLowerCase()] + || trimmedEnv[varName] + || trimmedEnv[varName.toLowerCase()]; if (varName == 'DISPLAY_UNITS') { if (value && value.toLowerCase().includes('mmol')) { @@ -162,7 +170,7 @@ function findExtendedSettings (envs) { extended.devicestatus = {}; extended.devicestatus.advanced = true; extended.devicestatus.days = 1; - if(process.env['DEVICESTATUS_DAYS'] && process.env['DEVICESTATUS_DAYS'] == '2') extended.devicestatus.days = 1; + if(trimmedEnv['DEVICESTATUS_DAYS'] && trimmedEnv['DEVICESTATUS_DAYS'] == '2') extended.devicestatus.days = 1; function normalizeEnv (key) { return key.toUpperCase().replace('CUSTOMCONNSTR_', ''); diff --git a/lib/settings.js b/lib/settings.js index 567e5d3f249..845cea07628 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -206,7 +206,9 @@ function init () { function getAndPrepare (key) { var raw = accessor(nameFromKey(key, nameType)) || ''; var cleaned = decodeURIComponent(raw).toLowerCase(); - return cleaned ? cleaned.split(' ') : []; + cleaned = cleaned ? cleaned.split(' ') : []; + cleaned = _.filter(cleaned, function(e) { return e !== ""; } ); + return cleaned; } function enableIf (feature, condition) { From f8461655bc8f6e0b178cbe7ef61c35b49b30f5e4 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Fri, 25 Sep 2020 12:53:31 +0300 Subject: [PATCH 10/24] * Fix security test * Change boot and caching to expose Mongo connection errors --- env.js | 19 +++--- lib/server/booterror.js | 12 +++- lib/server/bootevent.js | 9 ++- lib/storage/mongo-storage.js | 118 ++++++++++++++++++++--------------- tests/security.test.js | 6 +- views/service-worker.js | 1 - 6 files changed, 96 insertions(+), 69 deletions(-) diff --git a/env.js b/env.js index 6bac00a5e51..275271bdf26 100644 --- a/env.js +++ b/env.js @@ -14,15 +14,18 @@ var env = { settings: require('./lib/settings')() }; -var trimmedEnv = {}; +var shadowEnv; // Module to constrain all config and environment parsing to one spot. // See README.md for info about all the supported ENV VARs function config ( ) { // Assume users will typo whitespaces into keys and values + + shadowEnv = {}; + Object.keys(process.env).forEach((key, index) => { - trimmedEnv[_trim(key)] = _trim(process.env[key]); + shadowEnv[_trim(key)] = _trim(process.env[key]); }); env.PORT = readENV('PORT', 1337); @@ -130,7 +133,7 @@ function updateSettings() { }); //should always find extended settings last - env.extendedSettings = findExtendedSettings(trimmedEnv); + env.extendedSettings = findExtendedSettings(shadowEnv); if (!readENVTruthy('TREATMENTS_AUTH', true)) { env.settings.authDefaultRoles = env.settings.authDefaultRoles || ""; @@ -140,10 +143,10 @@ function updateSettings() { function readENV(varName, defaultValue) { //for some reason Azure uses this prefix, maybe there is a good reason - var value = trimmedEnv['CUSTOMCONNSTR_' + varName] - || trimmedEnv['CUSTOMCONNSTR_' + varName.toLowerCase()] - || trimmedEnv[varName] - || trimmedEnv[varName.toLowerCase()]; + var value = shadowEnv['CUSTOMCONNSTR_' + varName] + || shadowEnv['CUSTOMCONNSTR_' + varName.toLowerCase()] + || shadowEnv[varName] + || shadowEnv[varName.toLowerCase()]; if (varName == 'DISPLAY_UNITS') { if (value && value.toLowerCase().includes('mmol')) { @@ -170,7 +173,7 @@ function findExtendedSettings (envs) { extended.devicestatus = {}; extended.devicestatus.advanced = true; extended.devicestatus.days = 1; - if(trimmedEnv['DEVICESTATUS_DAYS'] && trimmedEnv['DEVICESTATUS_DAYS'] == '2') extended.devicestatus.days = 1; + if(shadowEnv['DEVICESTATUS_DAYS'] && shadowEnv['DEVICESTATUS_DAYS'] == '2') extended.devicestatus.days = 1; function normalizeEnv (key) { return key.toUpperCase().replace('CUSTOMCONNSTR_', ''); diff --git a/lib/server/booterror.js b/lib/server/booterror.js index f08eb06db39..d9ceaa7fb7b 100644 --- a/lib/server/booterror.js +++ b/lib/server/booterror.js @@ -9,8 +9,16 @@ function bootError(ctx) { return function pageHandler (req, res) { var errors = _.map(ctx.bootErrors, function (obj) { - obj.err = _.pick(obj.err, Object.getOwnPropertyNames(obj.err)); - return '
' + obj.desc + '
' + JSON.stringify(obj.err).replace(/\\n/g, '
') + '
'; + + let message; + + if (typeof obj.err === 'string' || obj.err instanceof String) { + console.log('Its a string'); + message = obj.err; + } else { + message = JSON.stringify(_.pick(obj.err, Object.getOwnPropertyNames(obj.err))); + } + return '
' + obj.desc + '
' + message.replace(/\\n/g, '
') + '
'; }).join(' '); res.set('Content-Type', 'text/html'); diff --git a/lib/server/bootevent.js b/lib/server/bootevent.js index 17f0319b360..ba6536564d5 100644 --- a/lib/server/bootevent.js +++ b/lib/server/bootevent.js @@ -107,7 +107,6 @@ function boot (env, language) { if (err) { throw err; } - ctx.store = store; console.log('OpenAPS Storage system ready'); next(); @@ -116,14 +115,18 @@ function boot (env, language) { //TODO assume mongo for now, when there are more storage options add a lookup require('../storage/mongo-storage')(env, function ready(err, store) { // FIXME, error is always null, if there is an error, the index.js will throw an exception + if (err) { + console.info('ERROR CONNECTING TO MONGO', err); + ctx.bootErrors = ctx.bootErrors || [ ]; + ctx.bootErrors.push({'desc': 'Unable to connect to Mongo', err: err}); + } console.log('Mongo Storage system ready'); ctx.store = store; - next(); }); } } catch (err) { - console.info('mongo err', err); + console.info('ERROR CONNECTING TO MONGO', err); ctx.bootErrors = ctx.bootErrors || [ ]; ctx.bootErrors.push({'desc': 'Unable to connect to Mongo', err: err}); next(); diff --git a/lib/storage/mongo-storage.js b/lib/storage/mongo-storage.js index 49c69b6d0f2..aaa86157ce4 100644 --- a/lib/storage/mongo-storage.js +++ b/lib/storage/mongo-storage.js @@ -1,12 +1,12 @@ 'use strict'; var mongodb = require('mongodb'); - +const { doesNotThrow } = require('should'); var connection = null; +var MongoClient = mongodb.MongoClient; +var mongo = {}; function init (env, cb, forceNewConnection) { - var MongoClient = mongodb.MongoClient; - var mongo = {}; function maybe_connect (cb) { @@ -24,59 +24,73 @@ function init (env, cb, forceNewConnection) { } console.log('Setting up new connection to MongoDB'); - var timeout = 30 * 1000; - var options = { reconnectInterval: 10000, reconnectTries: 500, connectTimeoutMS: timeout, - socketTimeoutMS: timeout, useNewUrlParser: true }; - - var connect_with_retry = async function(i) { - - try { - const client = await MongoClient.connect(env.storageURI, options); - console.log('Successfully established a connected to MongoDB'); - - var dbName = env.storageURI.split('/').pop().split('?'); - dbName=dbName[0]; // drop Connection Options - mongo.db = client.db(dbName); - connection = mongo.db; - mongo.client = client; - // If there is a valid callback, then invoke the function to perform the callback - - if (cb && cb.call) { - cb(null, mongo); - } - } catch (err) { - console.log('Mongo Error', err); - if (err.name && err.name === "MongoNetworkError") { - var timeout = (i > 15) ? 60000 : i*3000; - console.log('Error connecting to MongoDB: %j - retrying in ' + timeout/1000 + ' sec', err); - setTimeout(connect_with_retry, timeout, i+1); - } else { - throw new Error('MongoDB connection string '+env.storageURI+' seems invalid: '+ err.message) ; - } - } - + var timeout = 30 * 1000; + var options = { + reconnectInterval: 10000 + , reconnectTries: 500 + , connectTimeoutMS: timeout + , socketTimeoutMS: timeout + , useNewUrlParser: true }; - return connect_with_retry(1); + var connect_with_retry = function(i) { + + MongoClient.connect(env.storageURI, options) + .then(client => { + console.log('Successfully established a connected to MongoDB'); + + var dbName = env.storageURI.split('/').pop().split('?'); + dbName = dbName[0]; // drop Connection Options + mongo.db = client.db(dbName); + connection = mongo.db; + mongo.client = client; + // If there is a valid callback, then invoke the function to perform the callback + + if (cb && cb.call) { + cb(null, mongo); + } + }) + .catch(err => { + if (err.message && err.message.includes('AuthenticationFailed')) { + console.log('Authentication to Mongo failed'); + cb(new Error('MongoDB authentication failed! Double check the URL has the right username and password.'), null); + return; + } + + if (err.name && err.name === "MongoNetworkError") { + var timeout = (i > 15) ? 60000 : i * 3000; + console.log('Error connecting to MongoDB: %j - retrying in ' + timeout / 1000 + ' sec', err); + if (i) { + setTimeout(connect_with_retry, timeout, i + 1); + } + } else { + cb('MongoDB connection string ' + env.storageURI + ' seems invalid: ' + err.message); + } + }); + + }; + + return connect_with_retry(1); + + } } - } - mongo.collection = function get_collection (name) { - return connection.collection(name); - }; - - mongo.ensureIndexes = function ensureIndexes (collection, fields) { - fields.forEach(function (field) { - console.info('ensuring index for: ' + field); - collection.createIndex(field, { 'background': true }, function (err) { - if (err) { - console.error('unable to ensureIndex for: ' + field + ' - ' + err); - } + mongo.collection = function get_collection (name) { + return connection.collection(name); + }; + + mongo.ensureIndexes = function ensureIndexes (collection, fields) { + fields.forEach(function(field) { + console.info('ensuring index for: ' + field); + collection.createIndex(field, { 'background': true }, function(err) { + if (err) { + console.error('unable to ensureIndex for: ' + field + ' - ' + err); + } + }); }); - }); - }; + }; - return maybe_connect(cb); -} + return maybe_connect(cb); + } -module.exports = init; + module.exports = init; diff --git a/tests/security.test.js b/tests/security.test.js index 6b612f1bc6e..43524e6cd7d 100644 --- a/tests/security.test.js +++ b/tests/security.test.js @@ -2,14 +2,14 @@ var request = require('supertest'); var should = require('should'); -var load = require('./fixtures/load'); var language = require('../lib/language')(); describe('API_SECRET', function ( ) { - var api = require('../lib/api/'); - + var api; var scope = this; + function setup_app (env, fn) { + api = require('../lib/api/'); require('../lib/server/bootevent')(env, language).boot(function booted (ctx) { ctx.app = api(env, ctx); scope.app = ctx.app; diff --git a/views/service-worker.js b/views/service-worker.js index 0711889012b..d52441d303a 100644 --- a/views/service-worker.js +++ b/views/service-worker.js @@ -3,7 +3,6 @@ var CACHE = '<%= locals.cachebuster %>'; const CACHE_LIST = [ - '/', '/images/launch.png', '/images/apple-touch-icon-57x57.png', '/images/apple-touch-icon-60x60.png', From a8956bc4cccac4ed310c93327be1d10c3aa59c30 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Fri, 25 Sep 2020 14:07:46 +0300 Subject: [PATCH 11/24] Don't report an error on every reconnect --- lib/storage/mongo-storage.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/storage/mongo-storage.js b/lib/storage/mongo-storage.js index aaa86157ce4..be788957a5c 100644 --- a/lib/storage/mongo-storage.js +++ b/lib/storage/mongo-storage.js @@ -53,18 +53,17 @@ function init (env, cb, forceNewConnection) { .catch(err => { if (err.message && err.message.includes('AuthenticationFailed')) { console.log('Authentication to Mongo failed'); - cb(new Error('MongoDB authentication failed! Double check the URL has the right username and password.'), null); + cb(new Error('MongoDB authentication failed! Double check the URL has the right username and password in MONGO_CONNECTION.'), null); return; } if (err.name && err.name === "MongoNetworkError") { var timeout = (i > 15) ? 60000 : i * 3000; console.log('Error connecting to MongoDB: %j - retrying in ' + timeout / 1000 + ' sec', err); - if (i) { - setTimeout(connect_with_retry, timeout, i + 1); - } + setTimeout(connect_with_retry, timeout, i + 1); + if (i == 1) cb(new Error('MongoDB authentication failed! Double check the MONGO_CONNECTION setting in Heroku.'), null); } else { - cb('MongoDB connection string ' + env.storageURI + ' seems invalid: ' + err.message); + cb(new Error('MONGO_CONNECTION ' + env.storageURI + ' seems invalid: ' + err.message)); } }); From d3c1613132d73f047b3ba8e4f304aa18602c58d9 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Fri, 25 Sep 2020 18:43:31 +0300 Subject: [PATCH 12/24] Less intimidating error page on start, which also checks for api_secret --- app.js | 49 +++++++++++++----------------------- lib/server/booterror.js | 30 ++++++++++++++++------ lib/server/bootevent.js | 20 +++++++++++++++ lib/storage/mongo-storage.js | 4 +-- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/app.js b/app.js index f46db9c942e..c6528819bb9 100644 --- a/app.js +++ b/app.js @@ -132,8 +132,25 @@ function create (env, ctx) { )); }); + // Allow static resources to be cached for week + var maxAge = 7 * 24 * 60 * 60 * 1000; + + if (process.env.NODE_ENV === 'development') { + maxAge = 1; + console.log('Development environment detected, setting static file cache age to 1 second'); + } + + var staticFiles = express.static(env.static_files, { + maxAge + }); + + // serve the static content + app.use(staticFiles); + if (ctx.bootErrors && ctx.bootErrors.length > 0) { - app.get('*', require('./lib/server/booterror')(ctx)); + const bootErrorView = require('./lib/server/booterror')(env, ctx); + bootErrorView.setLocals(app.locals); + app.get('*', bootErrorView); return app; } @@ -256,36 +273,6 @@ function create (env, ctx) { res.sendFile(__dirname + '/swagger.yaml'); }); - /* // FOR DEBUGGING MEMORY LEEAKS - if (env.settings.isEnabled('dumps')) { - var heapdump = require('heapdump'); - app.get('/api/v2/dumps/start', function(req, res) { - var path = new Date().toISOString() + '.heapsnapshot'; - path = path.replace(/:/g, '-'); - console.info('writing dump to', path); - heapdump.writeSnapshot(path); - res.send('wrote dump to ' + path); - }); - } - */ - - // app.get('/package.json', software); - - // Allow static resources to be cached for week - var maxAge = 7 * 24 * 60 * 60 * 1000; - - if (process.env.NODE_ENV === 'development') { - maxAge = 1; - console.log('Development environment detected, setting static file cache age to 1 second'); - } - - var staticFiles = express.static(env.static_files, { - maxAge - }); - - // serve the static content - app.use(staticFiles); - // API docs const swaggerUi = require('swagger-ui-express'); diff --git a/lib/server/booterror.js b/lib/server/booterror.js index d9ceaa7fb7b..d8735e94451 100644 --- a/lib/server/booterror.js +++ b/lib/server/booterror.js @@ -1,19 +1,27 @@ 'use strict'; +const express = require('express'); +const path = require('path'); var _ = require('lodash'); -var head = 'Nightscout - Boot Error

Nightscout - Boot Error

'; -var tail = '
'; +function bootError(env, ctx) { -function bootError(ctx) { + const app = new express(); + let locals = {}; + + app.set('view engine', 'ejs'); + app.engine('html', require('ejs').renderFile); + app.set("views", path.join(__dirname, "../../views/")); + + app.get('*', (req, res, next) => { + + if (req.url.includes('images')) return next(); - return function pageHandler (req, res) { var errors = _.map(ctx.bootErrors, function (obj) { let message; if (typeof obj.err === 'string' || obj.err instanceof String) { - console.log('Its a string'); message = obj.err; } else { message = JSON.stringify(_.pick(obj.err, Object.getOwnPropertyNames(obj.err))); @@ -21,10 +29,18 @@ function bootError(ctx) { return '
' + obj.desc + '
' + message.replace(/\\n/g, '
') + '
'; }).join(' '); - res.set('Content-Type', 'text/html'); - res.send(head + errors + tail); + res.render('error.html', { + errors, + locals + }); + }); + + app.setLocals = function (_locals) { + locals = _locals; } + + return app; } module.exports = bootError; \ No newline at end of file diff --git a/lib/server/bootevent.js b/lib/server/bootevent.js index ba6536564d5..93c6c7f2057 100644 --- a/lib/server/bootevent.js +++ b/lib/server/bootevent.js @@ -95,6 +95,25 @@ function boot (env, language) { } } + function checkSettings (ctx, next) { + + ctx.bootErrors = ctx.bootErrors || []; + + console.log('Checking settings'); + + if (!env.storageURI) { + ctx.bootErrors.push({'desc': 'Mandatory setting missing', + err: 'MONGODB_URI setting is missing, cannot connect to database'}); + } + + if (!env.api_secret) { + ctx.bootErrors.push({'desc': 'Mandatory setting missing', + err: 'API_SECRET setting is missing, cannot enable REST API'}); + } + + next(); + } + function setupStorage (ctx, next) { if (hasBootErrors(ctx)) { @@ -288,6 +307,7 @@ function boot (env, language) { .acquire(checkNodeVersion) .acquire(checkEnv) .acquire(augmentSettings) + .acquire(checkSettings) .acquire(setupStorage) .acquire(setupAuthorization) .acquire(setupInternals) diff --git a/lib/storage/mongo-storage.js b/lib/storage/mongo-storage.js index be788957a5c..c42d83b18a7 100644 --- a/lib/storage/mongo-storage.js +++ b/lib/storage/mongo-storage.js @@ -20,7 +20,7 @@ function init (env, cb, forceNewConnection) { } } else { if (!env.storageURI) { - throw new Error('MongoDB connection string is missing. Please set MONGO_CONNECTION environment variable'); + throw new Error('MongoDB connection string is missing. Please set MONGODB_URI environment variable'); } console.log('Setting up new connection to MongoDB'); @@ -63,7 +63,7 @@ function init (env, cb, forceNewConnection) { setTimeout(connect_with_retry, timeout, i + 1); if (i == 1) cb(new Error('MongoDB authentication failed! Double check the MONGO_CONNECTION setting in Heroku.'), null); } else { - cb(new Error('MONGO_CONNECTION ' + env.storageURI + ' seems invalid: ' + err.message)); + cb(new Error('MONGODB_URI ' + env.storageURI + ' seems invalid: ' + err.message)); } }); From eec131bc3b40dfe9cfd1bf11e508b231805b2e68 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Fri, 25 Sep 2020 18:43:51 +0300 Subject: [PATCH 13/24] Add file missing from last commit --- views/error.html | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 views/error.html diff --git a/views/error.html b/views/error.html new file mode 100644 index 00000000000..12237af941c --- /dev/null +++ b/views/error.html @@ -0,0 +1,64 @@ + + + + + + + + Nightscout - Boot error + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +

Oops - Nightscout had trouble starting

+ +

Don't panic, we can work this out! This happens to the best of us.

+

Check the errors below and then refer to the + troubleshooting documentation.

+ +

Errors occurred during startup:

+
<%- errors %>
+
+ + + + From 84c245c90fe402ce3c2b55f21c3179b6550160ae Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Fri, 25 Sep 2020 18:56:36 +0300 Subject: [PATCH 14/24] Fix tests --- tests/mongo-storage.test.js | 2 +- tests/security.test.js | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/mongo-storage.test.js b/tests/mongo-storage.test.js index 40699946e22..ee706906ff8 100644 --- a/tests/mongo-storage.test.js +++ b/tests/mongo-storage.test.js @@ -36,7 +36,7 @@ describe('mongo storage', function () { (function () { return require('../lib/storage/mongo-storage')(env, false, true); - }).should.throw('MongoDB connection string is missing. Please set MONGO_CONNECTION environment variable'); + }).should.throw('MongoDB connection string is missing. Please set MONGODB_URI environment variable'); done(); }); diff --git a/tests/security.test.js b/tests/security.test.js index 43524e6cd7d..1f182970019 100644 --- a/tests/security.test.js +++ b/tests/security.test.js @@ -18,21 +18,6 @@ describe('API_SECRET', function ( ) { }); } - it('should work fine absent', function (done) { - delete process.env.API_SECRET; - var env = require('../env')( ); - should.not.exist(env.api_secret); - setup_app(env, function (ctx) { - - ctx.app.enabled('api').should.equal(false); - ping_status(ctx.app, again); - function again ( ) { - ping_authorized_endpoint(ctx.app, 404, done); - } - }); - }); - - it('should work fail set unauthorized', function (done) { var known = 'b723e97aa97846eb92d5264f084b2823f57c4aa1'; delete process.env.API_SECRET; From 54a5b35578fa8ba5355c8ee3dd0fc60c740d0f1e Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 27 Sep 2020 12:01:48 +0300 Subject: [PATCH 15/24] Remove require statement breaking deploys --- lib/storage/mongo-storage.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/storage/mongo-storage.js b/lib/storage/mongo-storage.js index c42d83b18a7..ae1724bc0d3 100644 --- a/lib/storage/mongo-storage.js +++ b/lib/storage/mongo-storage.js @@ -1,7 +1,6 @@ 'use strict'; var mongodb = require('mongodb'); -const { doesNotThrow } = require('should'); var connection = null; var MongoClient = mongodb.MongoClient; var mongo = {}; From 4d2a731a53b67a1e8cfb489760788756b496586d Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 27 Sep 2020 14:54:33 +0300 Subject: [PATCH 16/24] Update --feature-request--.md --- .github/ISSUE_TEMPLATE/--feature-request--.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/--feature-request--.md b/.github/ISSUE_TEMPLATE/--feature-request--.md index a94a261abf8..293efbc9c1e 100644 --- a/.github/ISSUE_TEMPLATE/--feature-request--.md +++ b/.github/ISSUE_TEMPLATE/--feature-request--.md @@ -4,6 +4,10 @@ about: Suggest an idea for this project --- +**If you need support for Nightscout, PLEASE DO NOT FILE A TICKET HERE** +For support, please post a question to the "CGM in The Cloud" group in Facebook +(https://www.facebook.com/groups/cgminthecloud) or visit the WeAreNotWaiting Discord at https://discord.gg/zg7CvCQ + **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] From 5af8d6358cf179f2fe73aad36c87dac4d48b52de Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 27 Sep 2020 14:54:43 +0300 Subject: [PATCH 17/24] Update --bug-report.md --- .github/ISSUE_TEMPLATE/--bug-report.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/--bug-report.md b/.github/ISSUE_TEMPLATE/--bug-report.md index b0d61217880..417f1eee1f8 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.md +++ b/.github/ISSUE_TEMPLATE/--bug-report.md @@ -5,6 +5,10 @@ label: bug --- +**If you need support for Nightscout, PLEASE DO NOT FILE A TICKET HERE** +For support, please post a question to the "CGM in The Cloud" group in Facebook +(https://www.facebook.com/groups/cgminthecloud) or visit the WeAreNotWaiting Discord at https://discord.gg/zg7CvCQ + **Describe the bug** A clear and concise description of what the bug is. From 6d6cf3101196fee379c9d7c3f3f3b4f8d1c524ab Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 27 Sep 2020 14:56:22 +0300 Subject: [PATCH 18/24] Update --individual-troubleshooting-help.md --- .github/ISSUE_TEMPLATE/--individual-troubleshooting-help.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/--individual-troubleshooting-help.md b/.github/ISSUE_TEMPLATE/--individual-troubleshooting-help.md index ff6e27b7682..e4c9b15d63f 100644 --- a/.github/ISSUE_TEMPLATE/--individual-troubleshooting-help.md +++ b/.github/ISSUE_TEMPLATE/--individual-troubleshooting-help.md @@ -6,6 +6,8 @@ about: Getting help with your own individual setup of Nightscout Having issues getting Nightscout up and running? Instead of creating an issue here, please use one of the existing support channels for Nightscout. +The documentation for Nightscout lives at (https://nightscout.github.io) + The main support channel is on Facebook: please join the CGM In The Cloud Facebook group (https://www.facebook.com/groups/cgminthecloud) and start a post there. **Suggestions to include in your post when you are asking for help:** @@ -13,4 +15,4 @@ The main support channel is on Facebook: please join the CGM In The Cloud Facebo 2. Include which step you are on and what the problem is: ("*I deployed on Heroku, but I'm not seeing any BG data.*") 3. If possible, include a link to the version of documentation you are following ("*I'm following the OpenAPS Nightscout setup docs (https://openaps.readthedocs.io/en/latest/docs/While%20You%20Wait%20For%20Gear/nightscout-setup.html#nightscout-setup-with-heroku)*") -Other places you can find support and assistance for Nightscout include Gitter's [nightscout/public](https://gitter.im/nightscout/public) channel. +Other places you can find support and assistance for Nightscout include our Discord channel at (https://discord.gg/zg7CvCQ) From dfa86a9c6ae4493dfab0c5cd7cf593e87a21c9be Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 27 Sep 2020 17:16:42 +0300 Subject: [PATCH 19/24] Fix memory leak and cache update issues in 14.0.4 (#6133) * Fix a memory leak in 14.0.4 * Fix linter error in ddata.js * Move data retention periods to caching * Update _id of inserted entries and device status so merging to cache works correctly * Reset the data in ddata before updating * Fix typo on entry cache retention period * Have device status cache retention period follow configuration * Fix _id injection in treatments --- lib/data/dataloader.js | 36 +++++------- lib/data/ddata.js | 8 ++- lib/server/bootevent.js | 2 +- lib/server/cache.js | 45 +++++++-------- lib/server/devicestatus.js | 62 +++++++++++---------- lib/server/entries.js | 110 ++++++++++++++++++++----------------- lib/server/treatments.js | 11 +++- 7 files changed, 146 insertions(+), 128 deletions(-) diff --git a/lib/data/dataloader.js b/lib/data/dataloader.js index 0f1b3c7bd86..7074b18e1fb 100644 --- a/lib/data/dataloader.js +++ b/lib/data/dataloader.js @@ -2,7 +2,6 @@ const _ = require('lodash'); const async = require('async'); -const times = require('../times'); const fitTreatmentsToBGCurve = require('./treatmenttocurve'); const constants = require('../constants'); @@ -144,8 +143,11 @@ function init(env, ctx) { done(err, result); } - // clear treatments to the base set, we're going to merge from multiple queries - ddata.treatments = []; // ctx.cache.treatments ? _.cloneDeep(ctx.cache.treatments) : []; + // clear data we'll get from the cache + + ddata.treatments = []; + ddata.devicestatus = []; + ddata.entries = []; ddata.dbstats = {}; @@ -196,11 +198,8 @@ function loadEntries(ddata, ctx, callback) { if (!err && results) { - const ageFilter = ddata.lastUpdated - constants.TWO_DAYS; const r = ctx.ddata.processRawDataForRuntime(results); - ctx.cache.insertData('entries', r, ageFilter); - - const currentData = ctx.cache.getData('entries').reverse(); + const currentData = ctx.cache.insertData('entries', r).reverse(); const mbgs = []; const sgvs = []; @@ -324,12 +323,11 @@ function loadTreatments(ddata, ctx, callback) { ctx.treatments.list(tq, function(err, results) { if (!err && results) { - const ageFilter = ddata.lastUpdated - longLoad; - const r = ctx.ddata.processRawDataForRuntime(results); - // update cache - ctx.cache.insertData('treatments', r, ageFilter); - ddata.treatments = ctx.ddata.idMergePreferNew(ddata.treatments, ctx.cache.getData('treatments')); + // update cache and apply to runtime data + const r = ctx.ddata.processRawDataForRuntime(results); + const currentData = ctx.cache.insertData('treatments', r); + ddata.treatments = ctx.ddata.idMergePreferNew(ddata.treatments, currentData); } callback(); @@ -361,7 +359,6 @@ function loadProfileSwitchTreatments(ddata, ctx, callback) { ctx.treatments.list(tq, function(err, results) { if (!err && results) { ddata.treatments = mergeProcessSort(ddata.treatments, results); - //mergeToTreatments(ddata, results); } // Store last profile switch @@ -418,7 +415,6 @@ function loadLatestSingle(ddata, ctx, dataType, callback) { ctx.treatments.list(tq, function(err, results) { if (!err && results) { ddata.treatments = mergeProcessSort(ddata.treatments, results); - //mergeToTreatments(ddata, results); } callback(); }); @@ -473,16 +469,12 @@ function loadDeviceStatus(ddata, env, ctx, callback) { ctx.devicestatus.list(opts, function(err, results) { if (!err && results) { -// ctx.cache.devicestatus = mergeProcessSort(ctx.cache.devicestatus, results, ageFilter); - const ageFilter = ddata.lastUpdated - longLoad; + // update cache and apply to runtime data const r = ctx.ddata.processRawDataForRuntime(results); - ctx.cache.insertData('devicestatus', r, ageFilter); - - const res = ctx.cache.getData('devicestatus'); + const currentData = ctx.cache.insertData('devicestatus', r); - const res2 = _.map(res, function eachStatus(result) { - //result.mills = new Date(result.created_at).getTime(); + const res2 = _.map(currentData, function eachStatus(result) { if ('uploaderBattery' in result) { result.uploader = { battery: result.uploaderBattery @@ -492,7 +484,7 @@ function loadDeviceStatus(ddata, env, ctx, callback) { return result; }); - ddata.devicestatus = mergeProcessSort(ddata.devicestatus, res2, ageFilter); + ddata.devicestatus = mergeProcessSort(ddata.devicestatus, res2); } else { ddata.devicestatus = []; } diff --git a/lib/data/ddata.js b/lib/data/ddata.js index 65120782fc0..9389f10d360 100644 --- a/lib/data/ddata.js +++ b/lib/data/ddata.js @@ -32,13 +32,15 @@ function init () { Object.keys(obj).forEach(key => { if (typeof obj[key] === 'object' && obj[key]) { - if (obj[key].hasOwnProperty('_id')) { + if (Object.prototype.hasOwnProperty.call(obj[key], '_id')) { obj[key]._id = obj[key]._id.toString(); } - if (obj[key].hasOwnProperty('created_at') && !obj[key].hasOwnProperty('mills')) { + if (Object.prototype.hasOwnProperty.call(obj[key], 'created_at') + && !Object.prototype.hasOwnProperty.call(obj[key], 'mills')) { obj[key].mills = new Date(obj[key].created_at).getTime(); } - if (obj[key].hasOwnProperty('sysTime') && !obj[key].hasOwnProperty('mills')) { + if (Object.prototype.hasOwnProperty.call(obj[key], 'sysTime') + && !Object.prototype.hasOwnProperty.call(obj[key], 'mills')) { obj[key].mills = new Date(obj[key].sysTime).getTime(); } } diff --git a/lib/server/bootevent.js b/lib/server/bootevent.js index 93c6c7f2057..6247645b3d2 100644 --- a/lib/server/bootevent.js +++ b/lib/server/bootevent.js @@ -255,7 +255,7 @@ function boot (env, language) { }); ctx.bus.on('data-loaded', function updatePlugins ( ) { - console.info('reloading sandbox data'); + // console.info('reloading sandbox data'); var sbx = require('../sandbox')().serverInit(env, ctx); ctx.plugins.setProperties(sbx); ctx.notifications.initRequests(); diff --git a/lib/server/cache.js b/lib/server/cache.js index fca93afafde..1b2576ce029 100644 --- a/lib/server/cache.js +++ b/lib/server/cache.js @@ -23,27 +23,33 @@ function cache (env, ctx) { , entries: [] }; - const dataArray = [ - data.treatments - , data.devicestatus - , data.entries - ]; + const retentionPeriods = { + treatments: constants.ONE_HOUR * 60 + , devicestatus: env.extendedSettings.devicestatus && env.extendedSettings.devicestatus.days && env.extendedSettings.devicestatus.days == 2 ? constants.TWO_DAYS : constants.ONE_DAY + , entries: constants.TWO_DAYS + }; + function mergeCacheArrays (oldData, newData, retentionPeriod) { - function mergeCacheArrays (oldData, newData, ageLimit) { + const ageLimit = Date.now() - retentionPeriod; - var filtered = _.filter(newData, function hasId (object) { - const hasId = !_.isEmpty(object._id); - const isFresh = (ageLimit && object.mills >= ageLimit) || (!ageLimit); - return isFresh && hasId; - }); + var filteredOld = filterForAge(oldData, ageLimit); + var filteredNew = filterForAge(newData, ageLimit); - const merged = ctx.ddata.idMergePreferNew(oldData, filtered); + const merged = ctx.ddata.idMergePreferNew(filteredOld, filteredNew); return _.sortBy(merged, function(item) { return -item.mills; }); + function filterForAge(data, ageLimit) { + return _.filter(data, function hasId(object) { + const hasId = !_.isEmpty(object._id); + const isFresh = object.mills >= ageLimit; + return isFresh && hasId; + }); + } + } data.isEmpty = (datatype) => { @@ -54,20 +60,17 @@ function cache (env, ctx) { return _.cloneDeep(data[datatype]); } - data.insertData = (datatype, newData, retentionPeriod) => { - data[datatype] = mergeCacheArrays(data[datatype], newData, retentionPeriod); + data.insertData = (datatype, newData) => { + data[datatype] = mergeCacheArrays(data[datatype], newData, retentionPeriods[datatype]); + return data.getData(datatype); } function dataChanged (operation) { - //console.log('Cache data operation requested', operation); - if (!data[operation.type]) return; if (operation.op == 'remove') { - //console.log('Cache data delete event'); // if multiple items were deleted, flush entire cache if (!operation.changes) { - //console.log('Multiple items delete from cache, flushing all') data.treatments = []; data.devicestatus = []; data.entries = []; @@ -76,9 +79,8 @@ function cache (env, ctx) { } } - if (operation.op == 'update') { - //console.log('Cache data update event'); - data[operation.type] = mergeCacheArrays(data[operation.type], operation.changes); + if (operation.op == 'update') { + data[operation.type] = mergeCacheArrays(data[operation.type], operation.changes, retentionPeriods[operation.type]); } } @@ -96,7 +98,6 @@ function cache (env, ctx) { } return data; - } module.exports = cache; diff --git a/lib/server/devicestatus.js b/lib/server/devicestatus.js index f3515367428..e228f5b372c 100644 --- a/lib/server/devicestatus.js +++ b/lib/server/devicestatus.js @@ -5,33 +5,38 @@ var find_options = require('./query'); function storage (collection, ctx) { - function create(obj, fn) { - + function create (obj, fn) { + // Normalize all dates to UTC const d = moment(obj.created_at).isValid() ? moment.parseZone(obj.created_at) : moment(); obj.created_at = d.toISOString(); obj.utcOffset = d.utcOffset(); - - api().insert(obj, function (err, doc) { - if (err != null && err.message) { + + api().insertOne(obj, function(err, results) { + if (err !== null && err.message) { console.log('Error inserting the device status object', err.message); fn(err.message, null); return; } - ctx.bus.emit('data-update', { - type: 'devicestatus', - op: 'update', - changes: ctx.ddata.processRawDataForRuntime([doc]) - }); + if (!err) { + + if (!obj._id) obj._id = results.insertedIds[0]._id; - fn(null, doc.ops); + ctx.bus.emit('data-update', { + type: 'devicestatus' + , op: 'update' + , changes: ctx.ddata.processRawDataForRuntime([obj]) + }); + } + + fn(null, results.ops); ctx.bus.emit('data-received'); }); } - function last(fn) { - return list({count: 1}, function (err, entries) { + function last (fn) { + return list({ count: 1 }, function(err, entries) { if (entries && entries.length > 0) { fn(err, entries[0]); } else { @@ -44,18 +49,18 @@ function storage (collection, ctx) { return find_options(opts, storage.queryOpts); } - function list(opts, fn) { + function list (opts, fn) { // these functions, find, sort, and limit, are used to // dynamically configure the request, based on the options we've // been given // determine sort options - function sort ( ) { - return opts && opts.sort || {created_at: -1}; + function sort () { + return opts && opts.sort || { created_at: -1 }; } // configure the limit portion of the current query - function limit ( ) { + function limit () { if (opts && opts.count) { return this.limit(parseInt(opts.count)); } @@ -68,31 +73,31 @@ function storage (collection, ctx) { } // now just stitch them all together - limit.call(api( ) - .find(query_for(opts)) - .sort(sort( )) + limit.call(api() + .find(query_for(opts)) + .sort(sort()) ).toArray(toArray); } function remove (opts, fn) { - function removed(err, stat) { + function removed (err, stat) { ctx.bus.emit('data-update', { - type: 'devicestatus', - op: 'remove', - count: stat.result.n, - changes: opts.find._id + type: 'devicestatus' + , op: 'remove' + , count: stat.result.n + , changes: opts.find._id }); fn(err, stat); } - return api( ).remove( + return api().remove( query_for(opts), removed); } - function api() { + function api () { return ctx.store.collection(collection); } @@ -101,9 +106,10 @@ function storage (collection, ctx) { api.query_for = query_for; api.last = last; api.remove = remove; - api.aggregate = require('./aggregate')({ }, api); + api.aggregate = require('./aggregate')({}, api); api.indexedFields = [ 'created_at' + , 'NSCLIENT_ID' ]; return api; diff --git a/lib/server/entries.js b/lib/server/entries.js index f6b61024e7d..50c8e0cc41a 100644 --- a/lib/server/entries.js +++ b/lib/server/entries.js @@ -10,60 +10,60 @@ var moment = require('moment'); * Encapsulate persistent storage of sgv entries. \**********/ -function storage(env, ctx) { +function storage (env, ctx) { // TODO: Code is a little redundant. // query for entries from storage function list (opts, fn) { - // these functions, find, sort, and limit, are used to - // dynamically configure the request, based on the options we've - // been given + // these functions, find, sort, and limit, are used to + // dynamically configure the request, based on the options we've + // been given - // determine sort options - function sort ( ) { - return opts && opts.sort || {date: -1}; - } + // determine sort options + function sort () { + return opts && opts.sort || { date: -1 }; + } - // configure the limit portion of the current query - function limit ( ) { - if (opts && opts.count) { - return this.limit(parseInt(opts.count)); - } - return this; + // configure the limit portion of the current query + function limit () { + if (opts && opts.count) { + return this.limit(parseInt(opts.count)); } + return this; + } - // handle all the results - function toArray (err, entries) { - fn(err, entries); - } + // handle all the results + function toArray (err, entries) { + fn(err, entries); + } - // now just stitch them all together - limit.call(api( ) - .find(query_for(opts)) - .sort(sort( )) - ).toArray(toArray); + // now just stitch them all together + limit.call(api() + .find(query_for(opts)) + .sort(sort()) + ).toArray(toArray); } function remove (opts, fn) { - api( ).remove(query_for(opts), function (err, stat) { + api().remove(query_for(opts), function(err, stat) { ctx.bus.emit('data-update', { - type: 'entries', - op: 'remove', - count: stat.result.n, - changes: opts.find._id + type: 'entries' + , op: 'remove' + , count: stat.result.n + , changes: opts.find._id }); //TODO: this is triggering a read from Mongo, we can do better - ctx.bus.emit('data-received'); - fn(err, stat); - }); + ctx.bus.emit('data-received'); + fn(err, stat); + }); } // return writable stream to lint each sgv record passing through it // TODO: get rid of this? not doing anything now - function map ( ) { + function map () { return es.map(function iter (item, next) { return next(null, item); }); @@ -80,7 +80,7 @@ function storage(env, ctx) { create(result, fn); } // lint and store the entire list - return es.pipeline(map( ), es.writeArray(done)); + return es.pipeline(map(), es.writeArray(done)); } //TODO: implement @@ -91,9 +91,9 @@ function storage(env, ctx) { // store new documents using the storage mechanism function create (docs, fn) { // potentially a batch insert - var firstErr = null, - numDocs = docs.length, - totalCreated = 0; + var firstErr = null + , numDocs = docs.length + , totalCreated = 0; docs.forEach(function(doc) { @@ -106,15 +106,21 @@ function storage(env, ctx) { doc.sysTime = _sysTime.toISOString(); if (doc.dateString) doc.dateString = doc.sysTime; - var query = (doc.sysTime && doc.type) ? {sysTime: doc.sysTime, type: doc.type} : doc; - api( ).update(query, doc, {upsert: true}, function (err) { + var query = (doc.sysTime && doc.type) ? { sysTime: doc.sysTime, type: doc.type } : doc; + api().update(query, doc, { upsert: true }, function(err, updateResults) { firstErr = firstErr || err; - ctx.bus.emit('data-update', { - type: 'entries', - op: 'update', - changes: ctx.ddata.processRawDataForRuntime([doc]) - }); + if (!err) { + if (updateResults.result.upserted) { + doc._id = updateResults.result.upserted[0]._id + } + + ctx.bus.emit('data-update', { + type: 'entries' + , op: 'update' + , changes: ctx.ddata.processRawDataForRuntime([doc]) + }); + } if (++totalCreated === numDocs) { //TODO: this is triggering a read from Mongo, we can do better @@ -125,8 +131,8 @@ function storage(env, ctx) { }); } - function getEntry(id, fn) { - api( ).findOne({_id: ObjectID(id)}, function (err, entry) { + function getEntry (id, fn) { + api().findOne({ _id: ObjectID(id) }, function(err, entry) { if (err) { fn(err); } else { @@ -140,7 +146,7 @@ function storage(env, ctx) { } // closure to represent the API - function api ( ) { + function api () { // obtain handle usable for querying the collection associated // with these records return ctx.store.collection(env.entries_collection); @@ -154,15 +160,21 @@ function storage(env, ctx) { api.persist = persist; api.query_for = query_for; api.getEntry = getEntry; - api.aggregate = require('./aggregate')({ }, api); + api.aggregate = require('./aggregate')({}, api); api.indexedFields = [ 'date' + , 'type' + , 'sgv' + , 'mbg' + , 'sysTime' + , 'dateString' - , { 'type' : 1, 'date' : -1, 'dateString' : 1 } + + , { 'type': 1, 'date': -1, 'dateString': 1 } ]; return api; } @@ -176,7 +188,7 @@ storage.queryOpts = { , rssi: parseInt , noise: parseInt , mbg: parseInt - } + } , useEpoch: true }; diff --git a/lib/server/treatments.js b/lib/server/treatments.js index dad13b5b5d6..a9107ea99b2 100644 --- a/lib/server/treatments.js +++ b/lib/server/treatments.js @@ -53,9 +53,7 @@ function storage (env, ctx) { if (!err) { if (updateResults.result.upserted) { obj._id = updateResults.result.upserted[0]._id - //console.log('PERSISTENCE: treatment upserted', updateResults.result.upserted[0]); } - //console.log('Update result', updateResults.result); } // TODO document this feature @@ -72,7 +70,14 @@ function storage (env, ctx) { } query.created_at = pbTreat.created_at; - api( ).update(query, pbTreat, {upsert: true}, function pbComplete (err) { + api( ).update(query, pbTreat, {upsert: true}, function pbComplete (err, updateResults) { + + if (!err) { + if (updateResults.result.upserted) { + pbTreat._id = updateResults.result.upserted[0]._id + } + } + var treatments = _.compact([obj, pbTreat]); ctx.bus.emit('data-update', { From 0670c5a9133e6e30cafb99d3025671ba80555ab7 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 27 Sep 2020 17:23:49 +0300 Subject: [PATCH 20/24] Add error cat to error page --- static/images/errorcat.jpg | Bin 0 -> 48882 bytes views/error.html | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 static/images/errorcat.jpg diff --git a/static/images/errorcat.jpg b/static/images/errorcat.jpg new file mode 100644 index 0000000000000000000000000000000000000000..15c1fb058b0c27bb2cf313c8c9f858723cc930ae GIT binary patch literal 48882 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 KKS#*#@&5sVE`mt_ literal 0 HcmV?d00001 diff --git a/views/error.html b/views/error.html index 12237af941c..580618c4071 100644 --- a/views/error.html +++ b/views/error.html @@ -23,7 +23,7 @@ table { margin: auto; - width: 50%; + width: 70%; } td { @@ -40,14 +40,14 @@ -
+ - + -

Oops - Nightscout had trouble starting

+

Oops - Nightscout is having trouble

Don't panic, we can work this out! This happens to the best of us.

Check the errors below and then refer to the From 4ba636d0b25c120d877770f978748bb1b1b639e8 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Sun, 27 Sep 2020 17:33:11 +0300 Subject: [PATCH 21/24] Fix MONGODB_URI reference in error message --- lib/storage/mongo-storage.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/storage/mongo-storage.js b/lib/storage/mongo-storage.js index ae1724bc0d3..39c1a81e641 100644 --- a/lib/storage/mongo-storage.js +++ b/lib/storage/mongo-storage.js @@ -52,7 +52,7 @@ function init (env, cb, forceNewConnection) { .catch(err => { if (err.message && err.message.includes('AuthenticationFailed')) { console.log('Authentication to Mongo failed'); - cb(new Error('MongoDB authentication failed! Double check the URL has the right username and password in MONGO_CONNECTION.'), null); + cb(new Error('MongoDB authentication failed! Double check the URL has the right username and password in MONGODB_URI.'), null); return; } @@ -60,7 +60,7 @@ function init (env, cb, forceNewConnection) { var timeout = (i > 15) ? 60000 : i * 3000; console.log('Error connecting to MongoDB: %j - retrying in ' + timeout / 1000 + ' sec', err); setTimeout(connect_with_retry, timeout, i + 1); - if (i == 1) cb(new Error('MongoDB authentication failed! Double check the MONGO_CONNECTION setting in Heroku.'), null); + if (i == 1) cb(new Error('MongoDB connection failed! Double check the MONGODB_URI setting in Heroku.'), null); } else { cb(new Error('MONGODB_URI ' + env.storageURI + ' seems invalid: ' + err.message)); } From 0dd1254bf49d5decbd5398fa195353b97715cd36 Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Mon, 28 Sep 2020 09:36:21 +0300 Subject: [PATCH 22/24] Bump version to 14.0.6 --- npm-shrinkwrap.json | 2 +- package.json | 2 +- swagger.json | 2 +- swagger.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 3ba1035c896..14b8d343cf4 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,6 +1,6 @@ { "name": "nightscout", - "version": "14.0.5", + "version": "14.0.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 7ae6694563a..ec94894a060 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nightscout", - "version": "14.0.5", + "version": "14.0.6", "description": "Nightscout acts as a web-based CGM (Continuous Glucose Montinor) to allow multiple caregivers to remotely view a patients glucose data in realtime.", "license": "AGPL-3.0", "author": "Nightscout Team", diff --git a/swagger.json b/swagger.json index 5d3504ecf60..83f28a0469f 100755 --- a/swagger.json +++ b/swagger.json @@ -8,7 +8,7 @@ "info": { "title": "Nightscout API", "description": "Own your DData with the Nightscout API", - "version": "14.0.5", + "version": "14.0.6", "license": { "name": "AGPL 3", "url": "https://www.gnu.org/licenses/agpl.txt" diff --git a/swagger.yaml b/swagger.yaml index 1bd4a02a964..7429d96a309 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -4,7 +4,7 @@ servers: info: title: Nightscout API description: Own your DData with the Nightscout API - version: 14.0.5 + version: 14.0.6 license: name: AGPL 3 url: 'https://www.gnu.org/licenses/agpl.txt' From a8b53f54e5de973f98e1a8a512a88be8e167375b Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Mon, 28 Sep 2020 10:51:27 +0300 Subject: [PATCH 23/24] Support uploading device statuses in batches (#6147) * Support uploading device statuses in batches * Correctly report batch insertion results --- lib/server/devicestatus.js | 70 ++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/lib/server/devicestatus.js b/lib/server/devicestatus.js index e228f5b372c..bf71437d646 100644 --- a/lib/server/devicestatus.js +++ b/lib/server/devicestatus.js @@ -5,34 +5,51 @@ var find_options = require('./query'); function storage (collection, ctx) { - function create (obj, fn) { - - // Normalize all dates to UTC - const d = moment(obj.created_at).isValid() ? moment.parseZone(obj.created_at) : moment(); - obj.created_at = d.toISOString(); - obj.utcOffset = d.utcOffset(); - - api().insertOne(obj, function(err, results) { - if (err !== null && err.message) { - console.log('Error inserting the device status object', err.message); - fn(err.message, null); - return; - } + function create (statuses, fn) { - if (!err) { + if (!Array.isArray(statuses)) { statuses = [statuses]; } - if (!obj._id) obj._id = results.insertedIds[0]._id; + const r = []; + let errorOccurred = false; - ctx.bus.emit('data-update', { - type: 'devicestatus' - , op: 'update' - , changes: ctx.ddata.processRawDataForRuntime([obj]) - }); - } + for (let i = 0; i < statuses.length; i++) { - fn(null, results.ops); - ctx.bus.emit('data-received'); - }); + const obj = statuses[i]; + + if (errorOccurred) return; + + // Normalize all dates to UTC + const d = moment(obj.created_at).isValid() ? moment.parseZone(obj.created_at) : moment(); + obj.created_at = d.toISOString(); + obj.utcOffset = d.utcOffset(); + + api().insertOne(obj, function(err, results) { + if (err !== null && err.message) { + console.log('Error inserting the device status object', err.message); + errorOccurred = true; + fn(err.message, null); + return; + } + + if (!err) { + + if (!obj._id) obj._id = results.insertedIds[0]._id; + r.push(obj); + + ctx.bus.emit('data-update', { + type: 'devicestatus' + , op: 'update' + , changes: ctx.ddata.processRawDataForRuntime([obj]) + }); + + // Last object! Return results + if (i == statuses.length - 1) { + fn(null, r); + ctx.bus.emit('data-received'); + } + } + }); + }; } function last (fn) { @@ -109,7 +126,10 @@ function storage (collection, ctx) { api.aggregate = require('./aggregate')({}, api); api.indexedFields = [ 'created_at' - + + + + , 'NSCLIENT_ID' ]; return api; From 48f5578e8a0b13341ad0e781910f70d93764b86c Mon Sep 17 00:00:00 2001 From: Sulka Haro Date: Mon, 28 Sep 2020 11:12:42 +0300 Subject: [PATCH 24/24] Make empty cache detection a bit more aggressive to account for cache flush and data insert happening concurrently --- lib/server/cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/cache.js b/lib/server/cache.js index 1b2576ce029..88e5f77ca02 100644 --- a/lib/server/cache.js +++ b/lib/server/cache.js @@ -53,7 +53,7 @@ function cache (env, ctx) { } data.isEmpty = (datatype) => { - return data[datatype].length == 0; + return data[datatype].length < 20; } data.getData = (datatype) => {